@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,19 +1,32 @@
1
1
  import { describe, expect, it, vi } from "bun:test"
2
2
  import type { Hex } from "viem"
3
- import { type Attributes, i32 } from "../attr"
4
3
  import type { ArkivClient } from "../clients/baseClient"
5
4
  import { NoMoreResultsError } from "../errors"
5
+ import {
6
+ type AddrValue,
7
+ type ArkivValue,
8
+ type Attributes,
9
+ addr,
10
+ type BytesValue,
11
+ bytes,
12
+ dec,
13
+ defineSchema,
14
+ i32,
15
+ SchemaMismatchError,
16
+ type StrValue,
17
+ str,
18
+ } from "../schema"
6
19
  import type { RpcEntity } from "../types/rpcSchema"
7
20
  import { MAX_LIMIT } from "./engine"
8
21
  import { InvalidPredicateError, QueryError } from "./errors"
9
- import { eq, gte, or } from "./expression"
22
+ import { eq, exists, gte, or } from "./expression"
10
23
  import { SelectQueryBuilder } from "./queryBuilder"
11
- import type { ProjectedEntity } from "./selection"
24
+ import type { DynamicSelection, SelectArg, Selection } from "./selection"
12
25
 
13
- const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
26
+ const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as const
14
27
  const KEY = `0x${"ab".repeat(32)}` as const
15
28
 
16
- type Page = { data: RpcEntity[]; blockNumber: string; cursor?: string }
29
+ type Page = { data: RpcEntity[]; blockNumber: string; cursor?: string; count?: string }
17
30
 
18
31
  /** A client whose `arkiv_query` serves the given pages in order. */
19
32
  function makeClient(...pages: Page[]) {
@@ -35,7 +48,7 @@ function sent(request: ReturnType<typeof makeClient>["request"], call = 0) {
35
48
  describe("the query it sends", () => {
36
49
  it("ANDs everything across repeated where() calls", async () => {
37
50
  const { client, request } = makeClient()
38
- await new SelectQueryBuilder(client, { key: true })
51
+ await new SelectQueryBuilder(client, { $key: true })
39
52
  .where(gte("level", i32(10)))
40
53
  .where(eq("category", "docs"), or(eq("status", "open"), eq("status", "review")))
41
54
  .fetch()
@@ -49,13 +62,13 @@ describe("the query it sends", () => {
49
62
 
50
63
  it("takes an array of expressions", async () => {
51
64
  const { client, request } = makeClient()
52
- await new SelectQueryBuilder(client, { key: true }).where([eq("a", 1), eq("b", 2)]).fetch()
65
+ await new SelectQueryBuilder(client, { $key: true }).where([eq("a", 1), eq("b", 2)]).fetch()
53
66
  expect(sent(request).query).toBe("a = i32(1) AND b = i32(2)")
54
67
  })
55
68
 
56
69
  it("turns ownedBy and createdBy into typed system predicates", async () => {
57
70
  const { client, request } = makeClient()
58
- await new SelectQueryBuilder(client, { key: true })
71
+ await new SelectQueryBuilder(client, { $key: true })
59
72
  .where(eq("category", "docs"))
60
73
  .ownedBy(ACCOUNT)
61
74
  .createdBy(ACCOUNT)
@@ -69,20 +82,20 @@ describe("the query it sends", () => {
69
82
  it("replaces rather than ANDs a second ownedBy — two owners would match nothing", async () => {
70
83
  const other = "0x1111111111111111111111111111111111111111"
71
84
  const { client, request } = makeClient()
72
- await new SelectQueryBuilder(client, { key: true }).ownedBy(ACCOUNT).ownedBy(other).fetch()
85
+ await new SelectQueryBuilder(client, { $key: true }).ownedBy(ACCOUNT).ownedBy(other).fetch()
73
86
  expect(sent(request).query).toBe(`$owner = addr(${other})`)
74
87
  })
75
88
 
76
89
  it("is readable without running anything", () => {
77
90
  const { client } = makeClient()
78
- const builder = new SelectQueryBuilder(client, { key: true }).where(eq("a", 1))
91
+ const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
79
92
  expect(builder.toString()).toBe("a = i32(1)")
80
93
  expect(`${builder}`).toBe("a = i32(1)")
81
94
  })
82
95
 
83
96
  it("refuses a query with no filter, which the language cannot spell", async () => {
84
97
  const { client, request } = makeClient()
85
- const builder = new SelectQueryBuilder(client, { key: true })
98
+ const builder = new SelectQueryBuilder(client, { $key: true })
86
99
  expect(() => builder.toString()).toThrow(InvalidPredicateError)
87
100
  await expect(builder.fetch()).rejects.toThrow(/at least one filter/)
88
101
  expect(request).not.toHaveBeenCalled()
@@ -92,7 +105,9 @@ describe("the query it sends", () => {
92
105
  describe("the options it sends", () => {
93
106
  it("sends the resolved selection, and nothing else by default", async () => {
94
107
  const { client, request } = makeClient()
95
- await new SelectQueryBuilder(client, { key: true, attributes: true }).where(eq("a", 1)).fetch()
108
+ await new SelectQueryBuilder(client, { $key: true, $attributes: true })
109
+ .where(eq("a", 1))
110
+ .fetch()
96
111
 
97
112
  const { options } = sent(request)
98
113
  expect(options).toEqual({ select: expect.objectContaining({ key: true, attributes: true }) })
@@ -102,7 +117,7 @@ describe("the options it sends", () => {
102
117
 
103
118
  it("sends limit, cursor and atBlock as the RPC spells them", async () => {
104
119
  const { client, request } = makeClient()
105
- await new SelectQueryBuilder(client, { key: true })
120
+ await new SelectQueryBuilder(client, { $key: true })
106
121
  .where(eq("a", 1))
107
122
  .limit(100)
108
123
  .cursor("b64:abc")
@@ -119,7 +134,7 @@ describe("the options it sends", () => {
119
134
 
120
135
  it("rejects a page size the node would reject", async () => {
121
136
  const { client, request } = makeClient()
122
- const query = () => new SelectQueryBuilder(client, { key: true }).where(eq("a", 1))
137
+ const query = () => new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
123
138
  await expect(
124
139
  query()
125
140
  .limit(MAX_LIMIT + 1)
@@ -132,6 +147,55 @@ describe("the options it sends", () => {
132
147
  })
133
148
  })
134
149
 
150
+ describe("withCount", () => {
151
+ it("carries the total on every page, typed bigint", async () => {
152
+ const { client, request } = makeClient(
153
+ { data: [{ key: KEY }], blockNumber: "0x1", cursor: "b64:next", count: "0x2" },
154
+ { data: [{ key: KEY }], blockNumber: "0x1", count: "0x2" },
155
+ )
156
+ const first = await new SelectQueryBuilder(client, { $key: true })
157
+ .where(eq("a", 1))
158
+ .withCount()
159
+ .limit(1)
160
+ .fetch()
161
+ const second = await first.next()
162
+
163
+ const total: bigint = first.count
164
+ const again: bigint = second.count
165
+ expect(total).toBe(2n)
166
+ expect(again).toBe(2n)
167
+ expect(sent(request, 0).options.select).toMatchObject({ key: true, count: true })
168
+ expect(sent(request, 1).options.select).toMatchObject({ key: true, count: true })
169
+ })
170
+
171
+ it("fails rather than typing a missing count as bigint", async () => {
172
+ const { client } = makeClient({ data: [], blockNumber: "0x1" })
173
+ await expect(
174
+ new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).withCount().fetch(),
175
+ ).rejects.toThrow(/returned no count/)
176
+ })
177
+
178
+ it("leaves count undefined, in the type too, unless asked for", async () => {
179
+ const { client, request } = makeClient({ data: [], blockNumber: "0x1" })
180
+ const page = await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
181
+ const nothing: undefined = page.count
182
+ expect(nothing).toBeUndefined()
183
+ expect(sent(request).options.select).not.toHaveProperty("count")
184
+ })
185
+
186
+ it("survives from(), and leaves the builder it was called on uncounted", async () => {
187
+ const Posts = defineSchema("posts", { category: str().indexed() })
188
+ const { client, request } = makeClient({ data: [], blockNumber: "0x1", count: "0x0" })
189
+ const base = new SelectQueryBuilder(client, { $key: true }).where(eq("category", "news"))
190
+ const counted = base.withCount().from(Posts)
191
+ const page = await counted.fetch()
192
+ const total: bigint = page.count
193
+ expect(total).toBe(0n)
194
+ await base.fetch()
195
+ expect(sent(request, 1).options.select).not.toHaveProperty("count")
196
+ })
197
+ })
198
+
135
199
  describe("decoding", () => {
136
200
  it("builds entities from exactly the fields the row carries", async () => {
137
201
  const { client } = makeClient({
@@ -142,42 +206,70 @@ describe("decoding", () => {
142
206
  owner: ACCOUNT,
143
207
  expiresAt: "0x92e21",
144
208
  creationFlags: { readonly: false, permissionlessExtension: true, raw: 2 },
145
- contentType: "application/json",
146
- payload: "0x7b7d",
147
- attributeSchema: [{ name: "version", type: "i32" }],
209
+ attributeSchema: [
210
+ { name: "version", type: "i32", indexed: true },
211
+ { name: "body", type: "bytes", indexed: false },
212
+ ],
148
213
  attributes: [
149
214
  { name: "projectId", type: "str", value: "alice-123" },
150
215
  { name: "version", type: "i32", value: 4 },
151
216
  { name: "balance", type: "u256", value: "0xf4240" },
152
217
  { name: "score", type: "dec", value: "3.5" },
218
+ { name: "body", type: "bytes", value: "0x7b7d" },
153
219
  ],
154
220
  },
155
221
  ],
156
222
  })
157
223
 
158
- const page = await new SelectQueryBuilder(client, "*").where(eq("a", 1)).fetch()
224
+ const page = await new SelectQueryBuilder(client, {
225
+ $key: true,
226
+ $expiresAt: true,
227
+ $creationFlags: true,
228
+ $creator: true,
229
+ $createdAt: true,
230
+ $attributes: true,
231
+ $attributeSchema: true,
232
+ })
233
+ .where(eq("a", 1))
234
+ .fetch()
159
235
  const entity = page.entities[0]
160
236
 
161
- expect(entity.key).toBe(KEY)
162
- expect(entity.expiresAt).toBe(0x92e21n)
163
- expect(entity.creationFlags).toEqual({
237
+ expect(entity.$key).toBe(KEY)
238
+ expect(entity.$expiresAt).toBe(0x92e21n)
239
+ expect(entity.$creationFlags).toEqual({
164
240
  readonly: false,
165
241
  permissionlessExtension: true,
166
242
  raw: 2,
167
243
  })
168
- expect(entity.attributes).toEqual({
244
+ expect(entity.$attributes).toEqual({
169
245
  projectId: { type: "str", value: "alice-123" },
170
246
  version: { type: "i32", value: 4 },
171
247
  balance: { type: "u256", value: 1_000_000n },
172
248
  score: { type: "dec", value: "3.5" },
249
+ body: { type: "bytes", value: "0x7b7d" },
250
+ })
251
+ expect(entity.$attributeSchema).toEqual({
252
+ version: { type: "i32", indexed: true },
253
+ body: { type: "bytes", indexed: false },
173
254
  })
174
- expect(entity.attributeSchema).toEqual({ version: "i32" })
175
- expect(entity.toJson()).toEqual({})
176
255
  // Unselected stays undefined rather than becoming an empty value.
177
- expect(entity.creator).toBeUndefined()
178
- expect(entity.createdAt).toBeUndefined()
256
+ expect(entity.$creator).toBeUndefined()
257
+ expect(entity.$createdAt).toBeUndefined()
179
258
  expect(page.blockNumber).toBe(0x8e1ffn)
180
259
  })
260
+
261
+ it("leaves indexed undefined when the node does not say, rather than guessing", async () => {
262
+ const { client } = makeClient({
263
+ blockNumber: "0x1",
264
+ data: [{ key: KEY, attributeSchema: [{ name: "version", type: "i32" }] }],
265
+ })
266
+ const page = await new SelectQueryBuilder(client, { $attributeSchema: true })
267
+ .where(eq("a", 1))
268
+ .fetch()
269
+ expect(page.entities[0].$attributeSchema).toEqual({
270
+ version: { type: "i32", indexed: undefined },
271
+ })
272
+ })
181
273
  })
182
274
 
183
275
  describe("pagination", () => {
@@ -193,7 +285,7 @@ describe("pagination", () => {
193
285
  page("b64:2", "0x3"),
194
286
  page(undefined, "0x4"),
195
287
  )
196
- const builder = new SelectQueryBuilder(client, { key: true }).where(eq("a", 1)).limit(2)
288
+ const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).limit(2)
197
289
 
198
290
  const first = await builder.fetch()
199
291
  expect(first.entities).toHaveLength(2)
@@ -210,7 +302,7 @@ describe("pagination", () => {
210
302
 
211
303
  it("leaves an already-read page readable", async () => {
212
304
  const { client } = makeClient(page("b64:1", "0x1"), page(undefined, "0x2"))
213
- const first = await new SelectQueryBuilder(client, { key: true }).where(eq("a", 1)).fetch()
305
+ const first = await new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
214
306
  await first.next()
215
307
  // next() returns the following page rather than mutating this one.
216
308
  expect(first.entities).toHaveLength(1)
@@ -221,7 +313,7 @@ describe("pagination", () => {
221
313
  // The old heuristic was "fewer rows than the limit means the end", which mistakes a final page
222
314
  // that happens to be exactly full for a middle one. The node omits the cursor instead.
223
315
  const { client } = makeClient(page(undefined, "0x1", "0x2"))
224
- const result = await new SelectQueryBuilder(client, { key: true })
316
+ const result = await new SelectQueryBuilder(client, { $key: true })
225
317
  .where(eq("a", 1))
226
318
  .limit(2)
227
319
  .fetch()
@@ -231,7 +323,7 @@ describe("pagination", () => {
231
323
 
232
324
  it("pages the query it started with, not whatever the builder says later", async () => {
233
325
  const { client, request } = makeClient(page("b64:1", "0x1"), page(undefined, "0x2"))
234
- const builder = new SelectQueryBuilder(client, { key: true }).where(eq("a", 1))
326
+ const builder = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))
235
327
 
236
328
  const first = await builder.fetch()
237
329
  // The builder stays mutable and reusable. Re-reading it per page would send page 2 under a
@@ -248,8 +340,8 @@ describe("pagination", () => {
248
340
  it("walks every page through the async iterator", async () => {
249
341
  const { client } = makeClient(page("b64:1", "0x1", "0x2"), page(undefined, "0x3"))
250
342
  const keys: unknown[] = []
251
- for await (const entity of new SelectQueryBuilder(client, { key: true }).where(eq("a", 1))) {
252
- keys.push((entity as { key: string }).key)
343
+ for await (const entity of new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1))) {
344
+ keys.push((entity as { $key: string }).$key)
253
345
  }
254
346
  expect(keys).toEqual(["0x1", "0x2", "0x3"])
255
347
  })
@@ -270,7 +362,7 @@ describe("node rejections", () => {
270
362
  const data = { position: 14, expected: "typed literal", got: "3.5" }
271
363
  ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(-32001, data))
272
364
 
273
- const promise = new SelectQueryBuilder(client, { key: true }).where(eq("a", 1)).fetch()
365
+ const promise = new SelectQueryBuilder(client, { $key: true }).where(eq("a", 1)).fetch()
274
366
  await expect(promise).rejects.toThrow(QueryError)
275
367
 
276
368
  const error = (await promise.catch((e) => e)) as QueryError
@@ -293,7 +385,7 @@ describe("node rejections", () => {
293
385
  for (const [code, kind] of Object.entries(kinds)) {
294
386
  const { client } = makeClient()
295
387
  ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(rpcFailure(Number(code)))
296
- const error = await new SelectQueryBuilder(client, { key: true })
388
+ const error = await new SelectQueryBuilder(client, { $key: true })
297
389
  .where(eq("a", 1))
298
390
  .fetch()
299
391
  .catch((e) => e)
@@ -306,7 +398,7 @@ describe("node rejections", () => {
306
398
  const transportFailure = new Error("fetch failed")
307
399
  ;(client.request as ReturnType<typeof vi.fn>).mockRejectedValue(transportFailure)
308
400
 
309
- const error = await new SelectQueryBuilder(client, { key: true })
401
+ const error = await new SelectQueryBuilder(client, { $key: true })
310
402
  .where(eq("a", 1))
311
403
  .fetch()
312
404
  .catch((e) => e)
@@ -319,50 +411,367 @@ describe("the projected type", () => {
319
411
  it("narrows the entity to exactly the selected fields", async () => {
320
412
  const { client } = makeClient({
321
413
  blockNumber: "0x1",
322
- data: [{ key: KEY, owner: ACCOUNT, payload: "0x7b7d" }],
414
+ data: [{ key: KEY, owner: ACCOUNT }],
323
415
  })
324
416
 
325
- const page = await new SelectQueryBuilder<ProjectedEntity<{ key: true; owner: true }>>(client, {
326
- key: true,
327
- owner: true,
417
+ const page = await new SelectQueryBuilder<{ $key: true; $owner: true }>(client, {
418
+ $key: true,
419
+ $owner: true,
328
420
  })
329
421
  .where(eq("a", 1))
330
422
  .fetch()
331
423
 
332
424
  const entity = page.entities[0]
333
- expectType<Hex>(entity.key)
334
- expectType<Hex>(entity.owner)
425
+ expectType<Hex>(entity.$key)
426
+ expectType<Hex>(entity.$owner)
335
427
  // @ts-expect-error - creator was not selected
336
- entity.creator
337
- // @ts-expect-error - toJson comes with the payload, which was not selected
338
- entity.toJson()
428
+ entity.$creator
339
429
  })
340
430
 
341
- it("keeps the payload helpers when the payload is selected", async () => {
342
- const { client } = makeClient({ blockNumber: "0x1", data: [{ payload: "0x7b7d" }] })
343
- const page = await new SelectQueryBuilder<ProjectedEntity<{ payload: true }>>(client, {
344
- payload: true,
431
+ it("reads every attribute as a map with $attributes", async () => {
432
+ const { client } = makeClient({
433
+ blockNumber: "0x1",
434
+ data: [{ key: KEY, attributes: [{ name: "version", type: "i32", value: 4 }] }],
435
+ })
436
+ const page = await new SelectQueryBuilder<{ $key: true; $attributes: true }>(client, {
437
+ $key: true,
438
+ $attributes: true,
345
439
  })
346
440
  .where(eq("a", 1))
347
441
  .fetch()
348
442
 
349
- expect(page.entities[0].toJson()).toEqual({})
350
- expectType<Uint8Array>(page.entities[0].payload)
443
+ expectType<Attributes>(page.entities[0].$attributes)
444
+ expect(page.entities[0].$attributes).toEqual({ version: { type: "i32", value: 4 } })
445
+ })
446
+ })
447
+
448
+ describe("through a schema", () => {
449
+ /** Builds the way `client.select` does, so the selection is inferred as a literal. */
450
+ const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
451
+ new SelectQueryBuilder<S>(client, selection)
452
+
453
+ const Posts = defineSchema({
454
+ author: addr().indexed(),
455
+ category: str().indexed(),
456
+ score: dec().indexed(),
457
+ title: str(),
458
+ body: bytes(),
459
+ thumbnail: bytes().optional(),
460
+ })
461
+
462
+ const row: RpcEntity = {
463
+ key: KEY,
464
+ owner: ACCOUNT,
465
+ attributes: [
466
+ { name: "author", type: "addr", value: ACCOUNT },
467
+ { name: "title", type: "str", value: "Hello" },
468
+ { name: "body", type: "bytes", value: "0x2348" },
469
+ ],
470
+ }
471
+
472
+ it("asks for the selected columns by name, alongside the entity fields", async () => {
473
+ const { client, request } = makeClient()
474
+ await select(client, { $key: true, title: true, body: true })
475
+ .from(Posts)
476
+ .where(eq("category", "docs"))
477
+ .fetch()
478
+ expect(sent(request).options.select).toMatchObject({
479
+ key: true,
480
+ owner: false,
481
+ attributes: { title: true, body: true },
482
+ })
483
+ })
484
+
485
+ it("returns flat rows with each column typed by the schema", async () => {
486
+ const { client } = makeClient({ blockNumber: "0x1", data: [row] })
487
+ const page = await select(client, {
488
+ $key: true,
489
+ author: true,
490
+ title: true,
491
+ body: true,
492
+ thumbnail: true,
493
+ })
494
+ .from(Posts)
495
+ .where(eq("category", "docs"))
496
+ .fetch()
497
+
498
+ const [first] = page.entities
499
+ expect(first).toEqual({
500
+ $key: KEY,
501
+ author: addr(ACCOUNT),
502
+ title: str("Hello"),
503
+ body: bytes("0x2348"),
504
+ thumbnail: undefined,
505
+ })
506
+ expectType<Hex>(first.$key)
507
+ expectType<AddrValue>(first.author)
508
+ expectType<StrValue>(first.title)
509
+ expectType<BytesValue>(first.body)
510
+ expectType<BytesValue | undefined>(first.thumbnail)
511
+ // @ts-expect-error - score was not selected
512
+ first.score
513
+ })
514
+
515
+ it("decodes every page of a walk as rows", async () => {
516
+ const { client } = makeClient(
517
+ { blockNumber: "0x1", data: [row], cursor: "next" },
518
+ { blockNumber: "0x1", data: [row] },
519
+ )
520
+ const titles: string[] = []
521
+ for await (const entry of select(client, { title: true })
522
+ .from(Posts)
523
+ .where(eq("category", "docs"))) {
524
+ titles.push(entry.title.value)
525
+ }
526
+ expect(titles).toEqual(["Hello", "Hello"])
351
527
  })
352
528
 
353
- it("treats a named attribute subset as a selection of attributes", async () => {
529
+ it("fails the fetch on an entity that does not fit the schema, with onMismatch: throw", async () => {
354
530
  const { client } = makeClient({
355
531
  blockNumber: "0x1",
356
- data: [{ key: KEY, attributes: [{ name: "version", type: "i32", value: 4 }] }],
532
+ data: [{ key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }],
357
533
  })
358
- const page = await new SelectQueryBuilder<
359
- ProjectedEntity<{ key: true; attributes: { version: true } }>
360
- >(client, { key: true, attributes: { version: true } })
361
- .where(eq("a", 1))
534
+ await expect(
535
+ select(client, { $key: true, title: true })
536
+ .from(Posts, { onMismatch: "throw" })
537
+ .where(eq("category", "docs"))
538
+ .fetch(),
539
+ ).rejects.toThrow(SchemaMismatchError)
540
+ })
541
+
542
+ it("checks the filters against the schema before sending anything", async () => {
543
+ const { client, request } = makeClient()
544
+ const builder = select(client, { $key: true }).from(Posts)
545
+ // @ts-expect-error - title is not indexed
546
+ await expect(builder.where(eq("title", "Hello")).fetch()).rejects.toThrow(/not indexed/)
547
+ const bytesFilter = select(client, { $key: true }).from(Posts)
548
+ // @ts-expect-error - body is bytes, which is never indexed
549
+ await expect(bytesFilter.where(exists("body")).fetch()).rejects.toThrow(/never indexed/)
550
+ expect(request).not.toHaveBeenCalled()
551
+ })
552
+
553
+ it("rejects a selection naming something the schema does not have, at from()", () => {
554
+ const { client } = makeClient()
555
+ expect(() =>
556
+ select(client, { $key: true, nope: true })
557
+ // @ts-expect-error - nope is not a column
558
+ .from(Posts),
559
+ ).toThrow(/"nope", which is not a column/)
560
+ })
561
+
562
+ it("checks a selection built at runtime when from() is called", () => {
563
+ const { client } = makeClient()
564
+ const selection: SelectArg = JSON.parse('{"$key": true, "nope": true}')
565
+ expect(() => new SelectQueryBuilder(client, selection).from(Posts)).toThrow(
566
+ /"nope", which is not a column/,
567
+ )
568
+ })
569
+
570
+ it("sends the selection as it was when select() was called", async () => {
571
+ const { client, request } = makeClient()
572
+ const selection: Record<string, boolean> = { $key: true, title: true }
573
+ const builder = new SelectQueryBuilder(client, selection as Selection).from(Posts)
574
+ selection.author = true
575
+ await builder.where(eq("category", "docs")).fetch()
576
+ expect(sent(request).options.select).toMatchObject({ attributes: { title: true } })
577
+ })
578
+
579
+ it("carries the filters, owner, limit, cursor and block across from()", async () => {
580
+ const { client, request } = makeClient()
581
+ await new SelectQueryBuilder(client, { $key: true })
582
+ .where(eq("category", "docs"))
583
+ .ownedBy(ACCOUNT)
584
+ .limit(5)
585
+ .cursor("0xc0")
586
+ .atBlock(7n)
587
+ .from(Posts)
588
+ .fetch()
589
+ const { query, options } = sent(request)
590
+ expect(query).toBe(`category = str('docs') AND $owner = addr(${ACCOUNT})`)
591
+ expect(options).toMatchObject({ limit: "0x5", cursor: "0xc0", atBlock: "0x7" })
592
+ })
593
+
594
+ it("reads the same selection without a schema, untyped and unchecked", async () => {
595
+ const misfit = { key: KEY, attributes: [{ name: "title", type: "i32", value: 7 }] }
596
+ const { client, request } = makeClient({ blockNumber: "0x1", data: [row, misfit] })
597
+ const page = await select(client, { $key: true, title: true, body: true })
598
+ .where(eq("category", "docs"))
599
+ .fetch()
600
+
601
+ expect(sent(request).options.select).toMatchObject({
602
+ key: true,
603
+ attributes: { title: true, body: true },
604
+ })
605
+ // The same flat rows as through a schema; without one, nothing is checked, so the misfit
606
+ // comes back as it is and a missing attribute as undefined.
607
+ expect(page.entities).toEqual([
608
+ { $key: KEY, title: str("Hello"), body: bytes("0x2348") },
609
+ { $key: KEY, title: i32(7), body: undefined },
610
+ ])
611
+ const title: ArkivValue | undefined = page.entities[0]?.title
612
+ expect(title).toEqual(str("Hello"))
613
+ })
614
+
615
+ it("skips a mismatch by default, and throws on it with onMismatch: throw", async () => {
616
+ // Anyone can write an entity a query matches but the schema does not fit, so one such entity
617
+ // must not fail every read of the others unless the caller asks for that.
618
+ const misfit = { key: KEY, attributes: [{ name: "author", type: "addr", value: ACCOUNT }] }
619
+ const pages = () => makeClient({ blockNumber: "0x1", data: [row, misfit, row] }).client
620
+
621
+ const page = await select(pages(), { $key: true, title: true })
622
+ .from(Posts)
623
+ .where(eq("category", "docs"))
624
+ .fetch()
625
+ expect(page.entities.map((entry) => entry.title.value)).toEqual(["Hello", "Hello"])
626
+ expect(page.skipped).toBe(1)
627
+
628
+ await expect(
629
+ select(pages(), { $key: true, title: true })
630
+ .from(Posts, { onMismatch: "throw" })
631
+ .where(eq("category", "docs"))
632
+ .fetch(),
633
+ ).rejects.toThrow(SchemaMismatchError)
634
+ })
635
+
636
+ it("still throws errors that are not mismatches when skipping", async () => {
637
+ const { client } = makeClient({
638
+ blockNumber: "0x1",
639
+ data: [{ key: KEY, attributes: [{ name: "title", type: "nope", value: 1 }] }],
640
+ })
641
+ await expect(
642
+ select(client, { title: true })
643
+ .from(Posts, { onMismatch: "skip" })
644
+ .where(eq("category", "docs"))
645
+ .fetch(),
646
+ ).rejects.toThrow(/Unknown attribute type/)
647
+ })
648
+
649
+ it("skips on every page of a walk", async () => {
650
+ const misfit = { key: KEY, attributes: [] }
651
+ const { client } = makeClient(
652
+ { blockNumber: "0x1", data: [misfit], cursor: "next" },
653
+ { blockNumber: "0x1", data: [row] },
654
+ )
655
+ const titles: string[] = []
656
+ for await (const entry of select(client, { title: true })
657
+ .from(Posts, { onMismatch: "skip" })
658
+ .where(eq("category", "docs"))) {
659
+ titles.push(entry.title.value)
660
+ }
661
+ expect(titles).toEqual(["Hello"])
662
+ })
663
+
664
+ it("counts skips per page, so an empty page with a cursor is not mistaken for the end", async () => {
665
+ const misfit = { key: KEY, attributes: [] }
666
+ const { client } = makeClient(
667
+ { blockNumber: "0x1", data: [misfit, misfit], cursor: "next" },
668
+ { blockNumber: "0x1", data: [row] },
669
+ )
670
+ const first = await select(client, { title: true })
671
+ .from(Posts, { onMismatch: "skip" })
672
+ .where(eq("category", "docs"))
673
+ .fetch()
674
+ expect(first.entities).toEqual([])
675
+ expect(first.skipped).toBe(2)
676
+ expect(first.hasNextPage()).toBe(true)
677
+
678
+ const second = await first.next()
679
+ expect(second.entities).toHaveLength(1)
680
+ expect(second.skipped).toBe(0)
681
+ })
682
+
683
+ it("reports nothing skipped when not skipping, or without a schema", async () => {
684
+ const throughSchema = await select(makeClient({ blockNumber: "0x1", data: [row] }).client, {
685
+ title: true,
686
+ })
687
+ .from(Posts)
688
+ .where(eq("category", "docs"))
689
+ .fetch()
690
+ expect(throughSchema.skipped).toBe(0)
691
+
692
+ const plain = await new SelectQueryBuilder(makeClient().client, { $key: true })
693
+ .where(eq("category", "docs"))
362
694
  .fetch()
695
+ expect(plain.skipped).toBe(0)
696
+ })
697
+
698
+ it("leaves the builder it was called on untouched, so two schemas never share one", async () => {
699
+ const Users = defineSchema("users", { category: str().indexed(), name: str() })
700
+ const { client, request } = makeClient()
701
+ const base = new SelectQueryBuilder(client, "*").limit(5)
702
+ const posts = base.from(Posts).where(eq("category", "docs"))
703
+ const users = base.from(Users)
704
+
705
+ expect(posts).not.toBe(base)
706
+ expect(users).not.toBe(posts)
707
+ await users.where(eq("category", "people")).fetch()
708
+ await posts.fetch()
709
+ // Each carried what `base` held when it was made, and nothing the other added since.
710
+ expect(sent(request, 0).query).toBe("arkiv.schema = str('users') AND category = str('people')")
711
+ expect(sent(request, 0).options.limit).toBe("0x5")
712
+ expect(sent(request, 1).query).toBe("category = str('docs')")
713
+ expect(() => base.toString()).toThrow(/at least one filter/)
714
+ })
715
+
716
+ it("types a dynamic selection's rows with every field optional", async () => {
717
+ const { client } = makeClient({ blockNumber: "0x1", data: [row] })
718
+ const selection: SelectArg = { $key: true }
719
+ const page = await new SelectQueryBuilder<DynamicSelection>(client, selection)
720
+ .from(Posts)
721
+ .where(eq("category", "docs"))
722
+ .fetch()
723
+ const [first] = page.entities
724
+ expectType<StrValue | undefined>(first.title)
725
+ expect(first).toEqual({ $key: KEY })
726
+ })
727
+
728
+ it("rejects an onMismatch it does not know", () => {
729
+ const { client } = makeClient()
730
+ expect(() =>
731
+ select(client, { title: true }).from(Posts, { onMismatch: "ignore" as never }),
732
+ ).toThrow(/"throw" or "skip"/)
733
+ })
734
+
735
+ it("rejects something that is not a schema", () => {
736
+ const { client } = makeClient()
737
+ expect(() =>
738
+ new SelectQueryBuilder(client, "*").from({ columns: {} } as unknown as typeof Posts),
739
+ ).toThrow(/defineSchema/)
740
+ })
741
+ })
742
+
743
+ describe("through a named schema", () => {
744
+ const Posts = defineSchema("posts", { category: str().indexed(), title: str() })
745
+ const select = <const S extends Selection>(client: ArkivClient, selection: S) =>
746
+ new SelectQueryBuilder<S>(client, selection)
747
+
748
+ it("filters on the schema's tag, ahead of everything else", async () => {
749
+ const { client, request } = makeClient()
750
+ await select(client, { $key: true }).from(Posts).where(eq("category", "docs")).fetch()
751
+ expect(sent(request).query).toBe("arkiv.schema = str('posts') AND category = str('docs')")
752
+ })
363
753
 
364
- expectType<Attributes>(page.entities[0].attributes)
365
- expect(page.entities[0].attributes).toEqual({ version: { type: "i32", value: 4 } })
754
+ it("needs no other filter, so it can list every entity of the schema", async () => {
755
+ const { client, request } = makeClient()
756
+ await select(client, { $key: true }).from(Posts).fetch()
757
+ expect(sent(request).query).toBe("arkiv.schema = str('posts')")
758
+ })
759
+
760
+ it("checks the caller's filters, not the tag it adds itself", async () => {
761
+ const { client } = makeClient()
762
+ const builder = select(client, { $key: true }).from(Posts)
763
+ // @ts-expect-error - arkiv.schema is not a column
764
+ builder.where(eq("arkiv.schema", "comments"))
765
+ await expect(builder.fetch()).rejects.toThrow(/"arkiv.schema" is not a column/)
766
+ })
767
+
768
+ it("leaves an unnamed schema's query to its filters alone", async () => {
769
+ const { client, request } = makeClient()
770
+ await select(client, { $key: true })
771
+ .from(defineSchema({ category: str().indexed() }))
772
+ .where(eq("category", "docs"))
773
+ .fetch()
774
+ expect(sent(request).query).toBe("category = str('docs')")
366
775
  })
367
776
  })
368
777