@arkiv-network/sdk 0.8.1-dev.1 → 0.9.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +180 -61
  2. package/dist/chains/index.d.ts +17 -17
  3. package/dist/changeOwnership-Cy9gfyjN.d.cts +1160 -0
  4. package/dist/changeOwnership-Cy9gfyjN.d.cts.map +1 -0
  5. package/dist/changeOwnership-gXf-lBqI.d.ts +1160 -0
  6. package/dist/changeOwnership-gXf-lBqI.d.ts.map +1 -0
  7. package/dist/entity/index.cjs +4 -10
  8. package/dist/entity/index.d.cts +3 -3
  9. package/dist/entity/index.d.ts +3 -3
  10. package/dist/entity/index.js +3 -4
  11. package/dist/entity-BM1unrQf.cjs +41 -0
  12. package/dist/{entity-DJ0Gml9c.cjs → entity-BgaFJSaV.cjs} +4 -4
  13. package/dist/entity-CFAI1KyG.js +36 -0
  14. package/dist/entity-CFAI1KyG.js.map +1 -0
  15. package/dist/{entity-CniBgBSd.js → entity-CGqx_AU0.js} +4 -4
  16. package/dist/entity-CGqx_AU0.js.map +1 -0
  17. package/dist/{payload-AfUfSctb.d.ts → expirationTime-C7PZK49K.d.ts} +8 -37
  18. package/dist/expirationTime-C7PZK49K.d.ts.map +1 -0
  19. package/dist/expirationTime-CkIGNAg8.cjs +140 -0
  20. package/dist/{payload-D167BlJR.d.cts → expirationTime-DOB8V4B8.d.cts} +8 -37
  21. package/dist/expirationTime-DOB8V4B8.d.cts.map +1 -0
  22. package/dist/{expiry-Cgdb_dbq.js → expirationTime-UKPuA-LM.js} +62 -27
  23. package/dist/expirationTime-UKPuA-LM.js.map +1 -0
  24. package/dist/expiry-B48I-yTA.d.cts +40 -0
  25. package/dist/expiry-B48I-yTA.d.cts.map +1 -0
  26. package/dist/expiry-S2PUL8Hg.d.ts +40 -0
  27. package/dist/expiry-S2PUL8Hg.d.ts.map +1 -0
  28. package/dist/index-C-mMF5WZ.d.cts +735 -0
  29. package/dist/index-C-mMF5WZ.d.cts.map +1 -0
  30. package/dist/{index-BkqSnGJH.d.ts → index-C4zZqJ9S.d.ts} +5 -27
  31. package/dist/index-C4zZqJ9S.d.ts.map +1 -0
  32. package/dist/{index-CLhsFyVH.d.cts → index-CcVWYB6v.d.cts} +5 -27
  33. package/dist/index-CcVWYB6v.d.cts.map +1 -0
  34. package/dist/index-DCx2XC6S.d.ts +489 -0
  35. package/dist/index-DCx2XC6S.d.ts.map +1 -0
  36. package/dist/index-DLb7BfIw.d.ts +735 -0
  37. package/dist/index-DLb7BfIw.d.ts.map +1 -0
  38. package/dist/index-DzriOUm8.d.cts +489 -0
  39. package/dist/index-DzriOUm8.d.cts.map +1 -0
  40. package/dist/index.cjs +162 -146
  41. package/dist/index.d.cts +180 -133
  42. package/dist/index.d.cts.map +1 -1
  43. package/dist/index.d.ts +180 -133
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +93 -83
  46. package/dist/index.js.map +1 -1
  47. package/dist/params-BuIrJA9a.cjs +45 -0
  48. package/dist/params-DDJOnsNi.js +22 -0
  49. package/dist/params-DDJOnsNi.js.map +1 -0
  50. package/dist/query/index.cjs +9 -4
  51. package/dist/query/index.d.cts +6 -6
  52. package/dist/query/index.d.ts +6 -6
  53. package/dist/query/index.js +4 -5
  54. package/dist/query-D0KtuFh2.cjs +1625 -0
  55. package/dist/query-DpRDozmj.js +1417 -0
  56. package/dist/query-DpRDozmj.js.map +1 -0
  57. package/dist/schema/index.cjs +46 -0
  58. package/dist/schema/index.d.cts +2 -0
  59. package/dist/schema/index.d.ts +2 -0
  60. package/dist/schema/index.js +3 -0
  61. package/dist/{attr-BXTkpnoc.cjs → schema-B9AyXcgU.cjs} +513 -246
  62. package/dist/{attr-tmyREHtw.js → schema-DY4sqehl.js} +427 -244
  63. package/dist/schema-DY4sqehl.js.map +1 -0
  64. package/dist/types/index.cjs +1 -1
  65. package/dist/types/index.d.cts +5 -5
  66. package/dist/types/index.d.ts +5 -5
  67. package/dist/types/index.js +1 -1
  68. package/dist/utils/index.cjs +3 -5
  69. package/dist/utils/index.d.cts +3 -3
  70. package/dist/utils/index.d.ts +3 -3
  71. package/dist/utils/index.js +3 -3
  72. package/package.json +9 -12
  73. package/src/actions/public/getEntity.test.ts +69 -0
  74. package/src/actions/public/getEntity.ts +34 -20
  75. package/src/actions/public/predictEntityKeys.ts +8 -7
  76. package/src/actions/public/query.test.ts +58 -0
  77. package/src/actions/public/query.ts +41 -13
  78. package/src/actions/wallet/createEntity.ts +34 -63
  79. package/src/actions/wallet/executeBatch.test.ts +38 -0
  80. package/src/actions/wallet/executeBatch.ts +26 -9
  81. package/src/actions/wallet/extendEntity.ts +9 -8
  82. package/src/actions/wallet/patchEntity.ts +43 -52
  83. package/src/clients/createPublicClient.test.ts +10 -2
  84. package/src/clients/createWalletClient.ts +4 -1
  85. package/src/clients/decorators/arkivPublic.ts +123 -80
  86. package/src/clients/decorators/arkivWallet.ts +113 -65
  87. package/src/entity/create.test.ts +25 -21
  88. package/src/entity/expiry.ts +8 -6
  89. package/src/entity/flags.ts +1 -1
  90. package/src/entity/index.ts +2 -4
  91. package/src/entity/key.ts +3 -3
  92. package/src/entity/operations.ts +1 -1
  93. package/src/errors.ts +2 -11
  94. package/src/index.ts +18 -6
  95. package/src/query/builderBase.ts +166 -0
  96. package/src/query/countBuilder.test.ts +91 -0
  97. package/src/query/countBuilder.ts +67 -0
  98. package/src/query/engine.ts +9 -3
  99. package/src/query/errors.ts +24 -14
  100. package/src/query/expression.test.ts +129 -21
  101. package/src/query/expression.ts +323 -94
  102. package/src/query/index.ts +26 -4
  103. package/src/query/injection.test.ts +5 -11
  104. package/src/query/queryBuilder.test.ts +467 -58
  105. package/src/query/queryBuilder.ts +191 -98
  106. package/src/query/queryResult.ts +35 -9
  107. package/src/query/selection.test.ts +278 -24
  108. package/src/query/selection.ts +427 -86
  109. package/src/schema/attributes.test.ts +266 -0
  110. package/src/schema/attributes.ts +212 -0
  111. package/src/{attr → schema}/codec.test.ts +37 -5
  112. package/src/{attr → schema}/codec.ts +40 -11
  113. package/src/schema/column.ts +193 -0
  114. package/src/{attr → schema}/errors.ts +67 -10
  115. package/src/schema/index.ts +76 -0
  116. package/src/{attr → schema}/names.ts +4 -2
  117. package/src/schema/schema.test.ts +171 -0
  118. package/src/schema/schema.ts +250 -0
  119. package/src/{attr → schema}/types.ts +36 -30
  120. package/src/{attr → schema}/values.test.ts +62 -3
  121. package/src/{attr → schema}/values.ts +246 -65
  122. package/src/types/entity.test.ts +1 -44
  123. package/src/types/entity.ts +9 -55
  124. package/src/types/events.ts +1 -1
  125. package/src/types/index.ts +11 -4
  126. package/src/types/rpcSchema.ts +22 -16
  127. package/src/utils/arkivTransactions.test.ts +356 -116
  128. package/src/utils/arkivTransactions.ts +30 -38
  129. package/src/utils/entities.ts +10 -9
  130. package/src/utils/expirationTime.ts +5 -5
  131. package/src/utils/index.ts +0 -1
  132. package/src/utils/logger.test.ts +45 -0
  133. package/src/utils/logger.ts +5 -39
  134. package/src/utils/revert.test.ts +25 -0
  135. package/src/utils/revert.ts +28 -3
  136. package/dist/attr/index.cjs +0 -34
  137. package/dist/attr/index.d.cts +0 -2
  138. package/dist/attr/index.d.ts +0 -2
  139. package/dist/attr/index.js +0 -3
  140. package/dist/attr-tmyREHtw.js.map +0 -1
  141. package/dist/entity-B0t6sN7F.cjs +0 -71
  142. package/dist/entity-BIp_df9u.js +0 -67
  143. package/dist/entity-BIp_df9u.js.map +0 -1
  144. package/dist/entity-CniBgBSd.js.map +0 -1
  145. package/dist/expiry-BxOlvVWC.d.ts +0 -79
  146. package/dist/expiry-BxOlvVWC.d.ts.map +0 -1
  147. package/dist/expiry-CG9GhVXv.cjs +0 -135
  148. package/dist/expiry-Cgdb_dbq.js.map +0 -1
  149. package/dist/expiry-DRV29F1r.d.cts +0 -79
  150. package/dist/expiry-DRV29F1r.d.cts.map +0 -1
  151. package/dist/flags-DCD26uhe.cjs +0 -43
  152. package/dist/flags-DUT2gHEU.js +0 -33
  153. package/dist/flags-DUT2gHEU.js.map +0 -1
  154. package/dist/index-BkqSnGJH.d.ts.map +0 -1
  155. package/dist/index-C7fbakoI.d.cts +0 -278
  156. package/dist/index-C7fbakoI.d.cts.map +0 -1
  157. package/dist/index-CIcHPx2j.d.cts +0 -424
  158. package/dist/index-CIcHPx2j.d.cts.map +0 -1
  159. package/dist/index-CLhsFyVH.d.cts.map +0 -1
  160. package/dist/index-Cd2aaWme.d.ts +0 -424
  161. package/dist/index-Cd2aaWme.d.ts.map +0 -1
  162. package/dist/index-Cq6fa8AI.d.ts +0 -278
  163. package/dist/index-Cq6fa8AI.d.ts.map +0 -1
  164. package/dist/index-DaNeJUw8.d.ts +0 -955
  165. package/dist/index-DaNeJUw8.d.ts.map +0 -1
  166. package/dist/index-Dv16LO2s.d.cts +0 -955
  167. package/dist/index-Dv16LO2s.d.cts.map +0 -1
  168. package/dist/payload-AfUfSctb.d.ts.map +0 -1
  169. package/dist/payload-D167BlJR.d.cts.map +0 -1
  170. package/dist/payload-DE7wMztL.js +0 -92
  171. package/dist/payload-DE7wMztL.js.map +0 -1
  172. package/dist/payload-DqFweqiO.cjs +0 -108
  173. package/dist/query-9oojUdM7.cjs +0 -1128
  174. package/dist/query-Ber-lOJQ.js +0 -968
  175. package/dist/query-Ber-lOJQ.js.map +0 -1
  176. package/src/actions/public/getEntityCount.ts +0 -10
  177. package/src/attr/attributes.test.ts +0 -120
  178. package/src/attr/attributes.ts +0 -196
  179. package/src/attr/index.ts +0 -50
  180. package/src/types/mimeTypes.ts +0 -27
  181. package/src/utils/compression.test.ts +0 -32
  182. package/src/utils/compression.ts +0 -102
  183. package/src/utils/payload.ts +0 -36
  184. /package/src/{attr → schema}/decimal.ts +0 -0
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test"
2
2
  import { decodeAbiParameters, parseAbiParameters, toHex } from "viem"
3
- import { encodeAttributes } from "../attr/attributes"
4
- import { dec, i32, str, u256 } from "../attr/values"
3
+ import { encodeCreate } from "../schema/attributes"
4
+ import { defineSchema } from "../schema/schema"
5
+ import { bytes, dec, i32, str, u256 } from "../schema/values"
5
6
  import { InvalidCreationFlagsError } from "./errors"
6
7
  import { decodeCreationFlags, encodeCreationFlags } from "./flags"
7
8
  import { MAX_SALT, NO_SALT, predictEntityKey, randomSalt, resolveSalt, validateSalt } from "./key"
@@ -148,9 +149,10 @@ describe("the Create operation", () => {
148
149
  })
149
150
 
150
151
  it("carries attributes as (name, typeId, bytes), sorted ascending by name", () => {
152
+ const schema = defineSchema({ zebra: i32(), apple: str(), mango: u256() })
151
153
  const create = decodeCreate(
152
154
  build({
153
- attributes: encodeAttributes({ zebra: i32(1), apple: str("x"), mango: u256(2n) }),
155
+ attributes: encodeCreate(schema, { zebra: i32(1), apple: str("x"), mango: u256(2n) }),
154
156
  }).operationData,
155
157
  )
156
158
  const names = create.attributes.map((a) => a.name)
@@ -158,38 +160,40 @@ describe("the Create operation", () => {
158
160
  expect(create.attributes.map((a) => a.typeId)).toEqual([8, 4, 2]) // apple str, mango u256, zebra i32
159
161
  })
160
162
 
161
- it("sends the payload and content type as system cells, ahead of user attributes", () => {
163
+ it("sets bit 7 of the typeId on indexed columns, and only on them", () => {
164
+ const schema = defineSchema({ level: i32().indexed(), note: str(), body: bytes() })
162
165
  const create = decodeCreate(
163
166
  build({
164
- attributes: encodeAttributes(
165
- { level: i32(10) },
166
- { payload: new Uint8Array([0xde, 0xad]), contentType: "application/json" },
167
- ),
167
+ attributes: encodeCreate(schema, {
168
+ level: 10,
169
+ note: "hi",
170
+ body: new Uint8Array([0xde, 0xad]),
171
+ }),
168
172
  }).operationData,
169
173
  )
170
174
  const byName = Object.fromEntries(create.attributes.map((a) => [a.name, a]))
171
-
172
- // "$" sorts below every letter, so the system cells lead.
173
- expect(create.attributes[0].name).toBe(toHex("$contentType", { size: 32 }))
174
- expect(create.attributes[1].name).toBe(toHex("$payload", { size: 32 }))
175
-
176
- expect(byName[toHex("$payload", { size: 32 })]).toMatchObject({ typeId: 7, value: "0xdead" })
177
- expect(byName[toHex("$contentType", { size: 32 })].typeId).toBe(8)
178
- expect(byName[toHex("level", { size: 32 })].typeId).toBe(2)
175
+ expect(byName[toHex("level", { size: 32 })].typeId).toBe(0x80 | 2)
176
+ expect(byName[toHex("note", { size: 32 })].typeId).toBe(8)
177
+ expect(byName[toHex("body", { size: 32 })]).toMatchObject({ typeId: 7, value: "0xdead" })
179
178
  })
180
179
 
181
- it("omits the system cells entirely when there is no payload or content type", () => {
180
+ it("carries no cells at all for an entity with no columns", () => {
182
181
  const create = decodeCreate(
183
- build({ attributes: encodeAttributes({ level: i32(10) }) }).operationData,
182
+ build({ attributes: encodeCreate(defineSchema({}), {}) }).operationData,
184
183
  )
185
- expect(create.attributes).toHaveLength(1)
186
- expect(create.attributes[0].name).toBe(toHex("level", { size: 32 }))
184
+ expect(create.attributes).toHaveLength(0)
187
185
  })
188
186
 
189
187
  it("encodes each value type at its documented width", () => {
188
+ const schema = defineSchema({ n: i32(), big: u256(), d: dec(), s: str() })
190
189
  const create = decodeCreate(
191
190
  build({
192
- attributes: encodeAttributes({ n: i32(-42), big: u256(1n), d: dec("1.5"), s: str("hi") }),
191
+ attributes: encodeCreate(schema, {
192
+ n: i32(-42),
193
+ big: u256(1n),
194
+ d: dec("1.5"),
195
+ s: str("hi"),
196
+ }),
193
197
  }).operationData,
194
198
  )
195
199
  const byName = Object.fromEntries(create.attributes.map((a) => [a.name, a.value]))
@@ -50,7 +50,7 @@ export function makeExpiry(minLifetime: bigint, expiresAt: bigint | Date): Expir
50
50
  return { minLifetime, expiresAt } as Expiry
51
51
  }
52
52
 
53
- /** The two `uint256` values the ABI carries, with every `Date` already placed. */
53
+ /** The two `uint64` values the ABI carries, with every `Date` already placed. */
54
54
  export type EncodedExpiry = {
55
55
  /** Absolute target block height, or `0n` for a purely relative lifetime. */
56
56
  expiresAt: bigint
@@ -72,10 +72,12 @@ export type ExpiryContext = {
72
72
  /**
73
73
  * Resolves an expiry into the wire pair and the block the entity will expire at.
74
74
  *
75
- * Durations are converted to blocks at the nominal `BLOCK_TIME` (2 seconds), which makes them
76
- * **approximate**: block production is not a clock, so a lifetime asked for in days drifts if the
77
- * chain runs fast or slow. The block it was converted to comes back as
78
- * {@link ResolvedExpiry.target}, so the approximation is always observable rather than assumed.
75
+ * A duration arrives here already in blocks — {@link ExpirationTime} converted it at the nominal
76
+ * `BLOCK_TIME` (2 seconds) through {@link toBlocks} — and a `Date` deadline is placed against
77
+ * `currentBlock` at that same block time here. Both are **approximate**: block production is not a
78
+ * clock, so a lifetime asked for in days drifts if the chain runs fast or slow. The block the
79
+ * expiry resolves to against `currentBlock` comes back as {@link ResolvedExpiry.target}, so the
80
+ * approximation is always observable rather than assumed.
79
81
  *
80
82
  * @throws {InvalidExpiryError} If the expiry is malformed, does not fit the `uint64` the wire
81
83
  * carries, or would leave the entity dead on arrival.
@@ -85,7 +87,7 @@ export function resolveExpiry(expiry: Expiry, context: ExpiryContext): ResolvedE
85
87
 
86
88
  if (typeof expiry?.minLifetime !== "bigint" || expiry.expiresAt === undefined) {
87
89
  throw new InvalidExpiryError(
88
- "expires must be built with ExpirationTime, e.g. ExpirationTime.fromDays(30) or " +
90
+ "expiration must be built with ExpirationTime, e.g. ExpirationTime.fromDays(30) or " +
89
91
  "ExpirationTime.atBlock(1_200_000n)",
90
92
  )
91
93
  }
@@ -12,7 +12,7 @@ const PERMISSIONLESS_EXTENSION_BIT = 0b0000_0010
12
12
  */
13
13
  export type CreationFlags = {
14
14
  /**
15
- * The entity's attributes and payload can never be changed — only its expiry extended, its
15
+ * The entity's attributes can never be changed — only its expiry extended, its
16
16
  * ownership transferred, and the entity deleted.
17
17
  */
18
18
  readonly?: boolean
@@ -6,10 +6,8 @@ export {
6
6
  } from "./errors"
7
7
  export type { EntityEventName } from "./events"
8
8
  export { ENTITY_EVENTS_ABI } from "./events"
9
- export type { EncodedExpiry, Expiry, ExpiryContext, Lifetime, ResolvedExpiry } from "./expiry"
10
- export { resolveExpiry, toBlocks } from "./expiry"
9
+ export type { Expiry, Lifetime } from "./expiry"
11
10
  export type { CreationFlags, ResolvedCreationFlags } from "./flags"
12
- export { decodeCreationFlags, encodeCreationFlags } from "./flags"
13
11
  export type { SaltInput } from "./key"
14
12
  export { MAX_SALT, NO_SALT, predictEntityKey, randomSalt } from "./key"
15
- export { MAX_EXPIRES_AT, OperationType } from "./params"
13
+ export { MAX_EXPIRES_AT } from "./params"
package/src/entity/key.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Address, encodePacked, type Hex, keccak256 } from "viem"
2
- import type { CreateEntityParameters } from "../actions/wallet/createEntity"
2
+ import type { CreateEntityOptions } from "../actions/wallet/createEntity"
3
3
  import { ARKIV_ADDRESS } from "../consts"
4
4
  import { InvalidSaltError, NoRandomSourceError } from "./errors"
5
5
 
@@ -7,7 +7,7 @@ import { InvalidSaltError, NoRandomSourceError } from "./errors"
7
7
  export const MAX_SALT = 2n ** 128n - 1n
8
8
 
9
9
  /**
10
- * Opts a create out of salting, as {@link CreateEntityParameters.salt}: the key is then derived
10
+ * Opts a create out of salting, as {@link CreateEntityOptions.salt}: the key is then derived
11
11
  * from the owner and its nonce alone, so anyone who knows both can work it out before the create
12
12
  * lands.
13
13
  */
@@ -19,7 +19,7 @@ export const NO_SALT = Symbol("NO_SALT")
19
19
  export type SaltInput = bigint | typeof NO_SALT
20
20
 
21
21
  /**
22
- * A cryptographically random 128-bit salt — what {@link CreateEntityParameters.salt} defaults to.
22
+ * A cryptographically random 128-bit salt — what {@link CreateEntityOptions.salt} defaults to.
23
23
  *
24
24
  * The salt exists for unpredictability, not uniqueness: the per-owner nonce already guarantees
25
25
  * distinct keys. Random bits mean only the creator can predict the key of an entity they are about
@@ -1,5 +1,5 @@
1
1
  import { type Address, encodeAbiParameters, type Hex, parseAbi, parseAbiParameters } from "viem"
2
- import type { AbiAttribute } from "../attr/codec"
2
+ import type { AbiAttribute } from "../schema/codec"
3
3
  import type { EncodedExpiry } from "./expiry"
4
4
  import type { predictEntityKey } from "./key"
5
5
  import { OperationType } from "./params"
package/src/errors.ts CHANGED
@@ -37,19 +37,10 @@ export class EmptyPatchError extends Error {
37
37
 
38
38
  constructor(entityKey: string) {
39
39
  super(
40
- `The patch for entity ${entityKey} has nothing to apply. Pass "set" to write attributes, ` +
41
- '"unset" to remove them, or "payload"/"contentType" to replace the contents.',
40
+ `The patch for entity ${entityKey} has nothing to apply. Pass "set" to write columns, or ` +
41
+ '"unset" to remove optional ones.',
42
42
  )
43
43
  this.name = "EmptyPatchError"
44
44
  this.entityKey = entityKey
45
45
  }
46
46
  }
47
-
48
- export class InvalidContentTypeError extends Error {
49
- constructor(contentType: string) {
50
- super(
51
- `Invalid content type "${contentType}". Must follow RFC 2045 MIME grammar and be lowercase only (e.g. "text/plain").`,
52
- )
53
- this.name = "InvalidContentTypeError"
54
- }
55
- }
package/src/index.ts CHANGED
@@ -2,8 +2,6 @@
2
2
  * @module main
3
3
  */
4
4
 
5
- // the attribute type system
6
- export * from "./attr"
7
5
  // export main arkiv stuff
8
6
  export type { ArkivClient } from "./clients/baseClient"
9
7
  export type { PublicArkivClient } from "./clients/createPublicClient"
@@ -19,18 +17,32 @@ export * from "./errors"
19
17
  // types referenced by the public client, must be reachable from the package root
20
18
  // see #106
21
19
  export type {
22
- EntitySelection,
20
+ AttributeRef,
21
+ DynamicSelection,
23
22
  Expression,
23
+ FromOptions,
24
24
  FullEntity,
25
- ProjectedEntity,
25
+ FullRow,
26
+ KnownSystemFields,
27
+ NonEmptySelection,
28
+ PartialRow,
29
+ QueryBuilderBase,
26
30
  QueryResult,
31
+ ResultOf,
32
+ Row,
33
+ SchemaRef,
27
34
  SelectArg,
35
+ Selected,
36
+ Selection,
37
+ SelectionOf,
38
+ SystemField,
28
39
  } from "./query"
29
- export { SelectQueryBuilder } from "./query"
40
+ export { CountQueryBuilder, SelectQueryBuilder, SYSTEM_FIELDS } from "./query"
41
+ // schemas and the attribute type system
42
+ export * from "./schema"
30
43
  // re-export arkiv types in main index file
31
44
  export * from "./types"
32
45
  // re-export chosen utils
33
46
  export { chainFromName } from "./utils/chains"
34
47
  // every create needs an expiry, and this is the only way to build one
35
48
  export { type DeadlineOptions, ExpirationTime } from "./utils/expirationTime"
36
- export { jsonToPayload, stringToPayload } from "./utils/payload"
@@ -0,0 +1,166 @@
1
+ import type { Hex } from "viem"
2
+ import type { ArkivClient } from "../clients/baseClient"
3
+ import { addr, type ColumnsOf, SCHEMA_ATTRIBUTE, type Schema } from "../schema"
4
+ import { InvalidPredicateError } from "./errors"
5
+ import {
6
+ and,
7
+ checkAgainstSchema,
8
+ type Expression,
9
+ eq,
10
+ type RefsOf,
11
+ type SchemaRef,
12
+ } from "./expression"
13
+
14
+ /** The expressions in a `where` call — passed one by one, or as one array. */
15
+ type WhereExpressions<E extends readonly (Expression | readonly Expression[])[]> =
16
+ E[number] extends infer X ? (X extends readonly Expression[] ? X[number] : X) : never
17
+
18
+ /**
19
+ * Human-readable messages for compile-time errors when a filter does not fit the schema.
20
+ */
21
+ type FilterProblem<R, S extends Schema> = R extends {
22
+ readonly name: infer N extends string
23
+ readonly type: infer T extends string
24
+ }
25
+ ? R extends SchemaRef<S>
26
+ ? never
27
+ : N extends SchemaRef<S>["name"]
28
+ ? `"${N}" has type ${Extract<SchemaRef<S>, { name: N }>["type"]}, but is compared against a value of type ${T}`
29
+ : N extends keyof ColumnsOf<S>
30
+ ? ColumnsOf<S>[N]["type"] extends "bytes"
31
+ ? `"${N}" is a bytes column, and bytes are never indexed, so it cannot be filtered on. Index a str, bytes32 or key derived from it instead`
32
+ : `"${N}" is not indexed, so it cannot be filtered on. Declare it ${ColumnsOf<S>[N]["type"]}().indexed() to filter on it`
33
+ : `"${N}" is not a column of this schema`
34
+ : never
35
+
36
+ /**
37
+ * `where`'s check that every filter fits the schema — `unknown` when it does, or when there is no
38
+ * schema, and otherwise an object whose one property is named after the problem. No expression has
39
+ * that property, so the compiler reports the sentence as the property it is missing.
40
+ */
41
+ type FilterCheck<
42
+ E extends readonly (Expression | readonly Expression[])[],
43
+ TSchema,
44
+ > = TSchema extends Schema
45
+ ? [FilterProblem<RefsOf<WhereExpressions<E>>, TSchema>] extends [infer P extends string]
46
+ ? [P] extends [never]
47
+ ? unknown
48
+ : { [K in P]: never }
49
+ : unknown
50
+ : unknown
51
+
52
+ /**
53
+ * What every query builder shares: the client, the filters — `where`, `ownedBy`, `createdBy` and a
54
+ * named schema's tag — and the block to read at. `client.select()` and `client.count()` build on it,
55
+ * so the same filters always send the same query string, whichever of them runs it.
56
+ *
57
+ * Not constructed directly: create a builder with `client.select(...)` or `client.count()`.
58
+ *
59
+ * @typeParam TSchema - The schema named with `from()`, if any, which `where` is checked against.
60
+ */
61
+ export abstract class QueryBuilderBase<TSchema extends Schema | undefined = undefined> {
62
+ protected readonly _client: ArkivClient
63
+ protected _schema: Schema | undefined
64
+ protected readonly _filters: Expression[] = []
65
+ protected _ownedBy: Expression | undefined
66
+ protected _createdBy: Expression | undefined
67
+ protected _atBlock: bigint | undefined
68
+
69
+ constructor(client: ArkivClient) {
70
+ this._client = client
71
+ }
72
+
73
+ /**
74
+ * This builder's state in a new builder of the same class, re-typed by the caller. Every field is
75
+ * carried over, so one a subclass adds is not dropped by `from()`; the filter list, which is
76
+ * mutated in place, is copied rather than shared.
77
+ *
78
+ * A copy, not `this` re-typed: a builder typed for one schema must never be the same object as
79
+ * one typed for another, or `base.from(Posts)` and `base.from(Users)` would share their state.
80
+ */
81
+ protected copy<B extends QueryBuilderBase<Schema | undefined>>(): B {
82
+ const copy = Object.create(Object.getPrototypeOf(this)) as B
83
+ return Object.assign(copy, this, { _filters: [...this._filters] })
84
+ }
85
+
86
+ /**
87
+ * Adds filters. Everything passed here, and across repeated calls, must hold.
88
+ *
89
+ * After `from()`, every attribute a filter names must be an indexed column of the schema,
90
+ * compared against a value of its type — anything else is a compile error.
91
+ *
92
+ * @param expressions - The expressions, as separate arguments or one array.
93
+ *
94
+ * @example
95
+ * builder.where(eq("category", "docs"))
96
+ * builder.where(gte("level", i32(10)), lt("level", i32(20)))
97
+ * builder.where(or(eq("status", "open"), not(exists("closedAt"))))
98
+ */
99
+ where<const E extends readonly (Expression | readonly Expression[])[]>(
100
+ ...expressions: E & FilterCheck<E, TSchema>
101
+ ): this {
102
+ this._filters.push(...expressions.flat())
103
+ return this
104
+ }
105
+
106
+ /**
107
+ * Restricts the query to entities this account owns — shorthand for
108
+ * `where(eq("$owner", addr(owner)))`. Calling it again replaces the filter.
109
+ *
110
+ * @example
111
+ * builder.ownedBy("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
112
+ */
113
+ ownedBy(owner: Hex): this {
114
+ this._ownedBy = eq("$owner", addr(owner))
115
+ return this
116
+ }
117
+
118
+ /**
119
+ * Restricts the query to entities this account created — shorthand for
120
+ * `where(eq("$creator", addr(creator)))`. Calling it again replaces the filter.
121
+ *
122
+ * The creator never changes; the owner does, so these differ after a transfer.
123
+ */
124
+ createdBy(creator: Hex): this {
125
+ this._createdBy = eq("$creator", addr(creator))
126
+ return this
127
+ }
128
+
129
+ /**
130
+ * Reads the state as of a given block rather than the head. The block must be within the range
131
+ * the node retains.
132
+ *
133
+ * @example
134
+ * builder.atBlock(1_297_000n)
135
+ */
136
+ atBlock(block: bigint): this {
137
+ this._atBlock = block
138
+ return this
139
+ }
140
+
141
+ /**
142
+ * The query string this builder will send — the same text the node parses: its filters,
143
+ * `ownedBy` and `createdBy`, and a named schema's tag, ANDed.
144
+ *
145
+ * @throws {InvalidPredicateError} If no filter has been added, or — after `from()` — a filter
146
+ * does not fit the schema. A named schema's own tag filter counts as a filter.
147
+ */
148
+ toString(): string {
149
+ const filters = [...this._filters]
150
+ if (this._ownedBy) filters.push(this._ownedBy)
151
+ if (this._createdBy) filters.push(this._createdBy)
152
+ const schema = this._schema
153
+ if (schema !== undefined) {
154
+ for (const filter of filters) checkAgainstSchema(filter, schema)
155
+ // Added after the check: the tag is not a column, and the caller never wrote this filter.
156
+ if (schema.tag !== undefined) filters.unshift(eq(SCHEMA_ATTRIBUTE, schema.tag))
157
+ }
158
+ if (filters.length === 0) {
159
+ throw new InvalidPredicateError(
160
+ "A query needs at least one filter — the language has no spelling for 'match every " +
161
+ "entity'. Add a where(...) predicate, or narrow by ownedBy(...) / createdBy(...).",
162
+ )
163
+ }
164
+ return and(filters).toString()
165
+ }
166
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it, vi } from "bun:test"
2
+ import type { ArkivClient } from "../clients/baseClient"
3
+ import { dec, defineSchema, str } from "../schema"
4
+ import { CountQueryBuilder } from "./countBuilder"
5
+ import { InvalidPredicateError } from "./errors"
6
+ import { eq, gt } from "./expression"
7
+
8
+ const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as const
9
+ const Posts = defineSchema("my-app.posts", { category: str().indexed(), score: dec().indexed() })
10
+
11
+ /** A client whose `arkiv_query` answers with `result`. */
12
+ function makeClient(result: unknown = { data: [], blockNumber: "0x5", count: "0x2a" }) {
13
+ const request = vi.fn(async () => result)
14
+ return { client: { request } as unknown as ArkivClient, request }
15
+ }
16
+
17
+ /** The `[queryString, options]` of the first `arkiv_query` call. */
18
+ function sent(request: ReturnType<typeof makeClient>["request"]) {
19
+ const [[call]] = request.mock.calls as unknown as [
20
+ [{ method: string; params: [string, Record<string, unknown>] }],
21
+ ]
22
+ return { method: call.method, query: call.params[0], options: call.params[1] }
23
+ }
24
+
25
+ describe("CountQueryBuilder", () => {
26
+ it("asks for the count alone and resolves to it", async () => {
27
+ const { client, request } = makeClient()
28
+ const total = await new CountQueryBuilder(client).where(eq("category", "news")).fetch()
29
+
30
+ expect(total).toBe(42n)
31
+ expect(sent(request).method).toBe("arkiv_query")
32
+ expect(sent(request).query).toBe("category = str('news')")
33
+ // Nothing but the count is selected, so the node sends no rows and no cursor.
34
+ expect(sent(request).options).toEqual({ select: { count: true } })
35
+ })
36
+
37
+ it("filters as select() does: schema tag, where, owner, creator and block", async () => {
38
+ const { client, request } = makeClient()
39
+ await new CountQueryBuilder(client)
40
+ .from(Posts)
41
+ .where(gt("score", dec("4")))
42
+ .ownedBy(ACCOUNT)
43
+ .createdBy(ACCOUNT)
44
+ .atBlock(5n)
45
+ .fetch()
46
+
47
+ expect(sent(request).query).toBe(
48
+ "arkiv.schema = str('my-app.posts') AND score > dec(4) AND " +
49
+ `$owner = addr(${ACCOUNT}) AND $creator = addr(${ACCOUNT})`,
50
+ )
51
+ expect(sent(request).options).toEqual({ select: { count: true }, atBlock: "0x5" })
52
+ })
53
+
54
+ it("needs no other filter through a named schema", async () => {
55
+ const { client, request } = makeClient()
56
+ await new CountQueryBuilder(client).from(Posts).fetch()
57
+ expect(sent(request).query).toBe("arkiv.schema = str('my-app.posts')")
58
+ })
59
+
60
+ it("checks filters against the schema, at compile time and when it runs", async () => {
61
+ const { client } = makeClient()
62
+ // @ts-expect-error - score is a dec column
63
+ const wrongType = new CountQueryBuilder(client).from(Posts).where(eq("score", 5))
64
+ await expect(wrongType.fetch()).rejects.toThrow(InvalidPredicateError)
65
+ })
66
+
67
+ it("rejects a count with no filter at all", async () => {
68
+ const { client, request } = makeClient()
69
+ await expect(new CountQueryBuilder(client).fetch()).rejects.toThrow(InvalidPredicateError)
70
+ expect(request).not.toHaveBeenCalled()
71
+ })
72
+
73
+ it("fails rather than guessing when the node sends no count", async () => {
74
+ const { client } = makeClient({ data: [], blockNumber: "0x1" })
75
+ await expect(new CountQueryBuilder(client).where(eq("a", 1)).fetch()).rejects.toThrow(
76
+ /returned no count/,
77
+ )
78
+ })
79
+
80
+ it("leaves the builder from() was called on without the schema", async () => {
81
+ const { client, request } = makeClient()
82
+ const base = new CountQueryBuilder(client).where(eq("category", "news"))
83
+ await base.from(Posts).fetch()
84
+ await base.fetch()
85
+ const [, second] = request.mock.calls as unknown as [
86
+ unknown,
87
+ [{ params: [string, Record<string, unknown>] }],
88
+ ]
89
+ expect(second[0].params[0]).toBe("category = str('news')")
90
+ })
91
+ })
@@ -0,0 +1,67 @@
1
+ import { isSchema, type Schema, SchemaDefinitionError } from "../schema"
2
+ import { QueryBuilderBase } from "./builderBase"
3
+ import { runQuery } from "./engine"
4
+
5
+ /**
6
+ * Builds and runs a count: how many entities a query matches, without fetching any of them.
7
+ *
8
+ * It filters exactly as `client.select()` does — {@link from}, `where`, `ownedBy`, `createdBy` and
9
+ * `atBlock` are the same methods, from the same base — but has nothing to select and no pages, so
10
+ * {@link fetch} resolves to a `bigint`. Create one with `client.count()`.
11
+ *
12
+ * To show the total next to a page of rows, use `select(...).withCount()` instead, which counts in
13
+ * the same request.
14
+ *
15
+ * @typeParam TSchema - The schema named with {@link from}, if any.
16
+ *
17
+ * @example
18
+ * const total = await client.count().from(Posts).ownedBy(account.address).fetch()
19
+ */
20
+ export class CountQueryBuilder<
21
+ TSchema extends Schema | undefined = undefined,
22
+ > extends QueryBuilderBase<TSchema> {
23
+ /**
24
+ * Counts through a schema: `where` is checked against its indexed columns, and a named schema
25
+ * counts only entities tagged with its name — so it needs no other filter.
26
+ *
27
+ * Returns a new builder, carrying whatever this one already holds; this one is left as it was.
28
+ *
29
+ * @throws {SchemaDefinitionError} If `schema` was not built with `defineSchema`.
30
+ *
31
+ * @example
32
+ * client.count().from(Posts) // every post
33
+ */
34
+ from<S extends Schema>(schema: S): CountQueryBuilder<S> {
35
+ if (!isSchema(schema)) {
36
+ throw new SchemaDefinitionError("from() takes a schema built with defineSchema(...)")
37
+ }
38
+ const typed = this.copy<CountQueryBuilder<S>>()
39
+ typed._schema = schema
40
+ return typed
41
+ }
42
+
43
+ /**
44
+ * Runs the count.
45
+ *
46
+ * Counts every entity the filters match at the block, whether or not it fits the schema — what
47
+ * `select().from(schema)` leaves out of its pages is still counted.
48
+ *
49
+ * @throws {InvalidPredicateError} If no filter has been added, or a filter does not fit the
50
+ * schema.
51
+ * @throws {QueryError} If the node rejects the query.
52
+ * @throws If the node returns no count — it predates the count projection.
53
+ */
54
+ async fetch(): Promise<bigint> {
55
+ // Nothing but the count is selected, so the node returns no rows and no cursor.
56
+ const response = await runQuery(this._client, {
57
+ query: this.toString(),
58
+ select: {},
59
+ atBlock: this._atBlock,
60
+ count: true,
61
+ })
62
+ if (response.count === undefined) {
63
+ throw new Error("The node returned no count. It may predate the count projection.")
64
+ }
65
+ return response.count
66
+ }
67
+ }
@@ -8,7 +8,7 @@ import { asQueryError } from "./errors"
8
8
 
9
9
  const logger = getLogger("query:engine")
10
10
 
11
- export const MAX_LIMIT = 200
11
+ export const MAX_LIMIT = 100
12
12
 
13
13
  /** One `arkiv_query` call. */
14
14
  export type QueryRequest = {
@@ -22,6 +22,8 @@ export type QueryRequest = {
22
22
  cursor?: string | undefined
23
23
  /** Block height to read at. Defaults to the head. */
24
24
  atBlock?: bigint | undefined
25
+ /** Whether to also count every entity the query matches. */
26
+ count?: boolean | undefined
25
27
  }
26
28
 
27
29
  /** One page of results. */
@@ -31,15 +33,18 @@ export type QueryResponse = {
31
33
  blockNumber: bigint
32
34
  /** The cursor for the next page, or `undefined` when this was the last one. */
33
35
  cursor: string | undefined
36
+ /** How many entities the query matches, if the request asked to count them. */
37
+ count: bigint | undefined
34
38
  }
35
39
 
36
40
  /**
37
41
  * Runs one query and decodes the page it returns.
38
42
  *
43
+ * @throws If `limit` is not an integer from 1 to {@link MAX_LIMIT}.
39
44
  * @throws {QueryError} If the node rejects the query.
40
45
  */
41
46
  export async function runQuery(client: ArkivClient, request: QueryRequest): Promise<QueryResponse> {
42
- const { query, select, limit, cursor, atBlock } = request
47
+ const { query, select, limit, cursor, atBlock, count } = request
43
48
 
44
49
  if (limit !== undefined) {
45
50
  if (!Number.isInteger(limit) || limit < 1) {
@@ -54,7 +59,7 @@ export async function runQuery(client: ArkivClient, request: QueryRequest): Prom
54
59
  }
55
60
 
56
61
  const queryOptions: RpcQueryOptions = {
57
- select,
62
+ select: count ? { ...select, count: true } : select,
58
63
  ...(atBlock !== undefined && { atBlock: numberToHex(atBlock) }),
59
64
  ...(limit !== undefined && { limit: numberToHex(limit) }),
60
65
  ...(cursor !== undefined && { cursor }),
@@ -74,5 +79,6 @@ export async function runQuery(client: ArkivClient, request: QueryRequest): Prom
74
79
  entities: result.data.map((rpcEntity) => entityFromRpcResult(rpcEntity)),
75
80
  blockNumber: BigInt(result.blockNumber),
76
81
  cursor: result.cursor,
82
+ count: result.count === undefined ? undefined : BigInt(result.count),
77
83
  }
78
84
  }