@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,101 +1,159 @@
1
- import type { Hex } from "viem"
2
- import { addr } from "../attr"
3
1
  import type { ArkivClient } from "../clients/baseClient"
2
+ import { isSchema, type Schema, SchemaDefinitionError, SchemaMismatchError } from "../schema"
4
3
  import type { Entity } from "../types/entity"
5
- import type { RpcSelect } from "../types/rpcSchema"
4
+ import { QueryBuilderBase } from "./builderBase"
6
5
  import { type QueryRequest, runQuery } from "./engine"
7
- import { InvalidPredicateError } from "./errors"
8
- import { and, type Expression, eq, type or } from "./expression"
6
+ import type { or } from "./expression"
9
7
  import { QueryResult } from "./queryResult"
10
- import { type SelectArg, toRpcSelect } from "./selection"
8
+ import {
9
+ type Projection,
10
+ projectSelection,
11
+ type ResultOf,
12
+ type SelectArg,
13
+ snapshotSelection,
14
+ toRow,
15
+ type UnknownSelection,
16
+ } from "./selection"
17
+
18
+ /**
19
+ * `from(schema)`'s check that every selected key is an entity field or a column — `unknown` when
20
+ * it holds, and an object naming the offenders, which no schema satisfies, when it does not.
21
+ */
22
+ type SelectionCheck<TSelection, TSchema extends Schema> = [
23
+ UnknownSelection<TSelection, TSchema>,
24
+ ] extends [never]
25
+ ? unknown
26
+ : { "selected names that are not columns of this schema": UnknownSelection<TSelection, TSchema> }
27
+
28
+ /**
29
+ * How a query through a schema treats a matching entity that does not fit the schema — one not
30
+ * tagged with a named schema's name, or missing a selected required column or holding one of
31
+ * another type. Columns the selection leaves out are not checked.
32
+ */
33
+ export type FromOptions = {
34
+ /**
35
+ * `"skip"` (the default) leaves the entity out of the page — so a page can hold fewer rows than
36
+ * its limit, and a page whose every entity was skipped is empty while later pages still have rows.
37
+ * Each page reports how many it left out as {@link QueryResult.skipped}. Arkiv is public, so
38
+ * anyone can write an entity that matches a query but not the schema; skipping keeps one such
39
+ * entity from failing every read of the others. `"throw"` fails the fetch with a
40
+ * {@link SchemaMismatchError} instead.
41
+ */
42
+ onMismatch?: "throw" | "skip" | undefined
43
+ }
11
44
 
12
45
  /**
13
46
  * Builds and runs a query.
14
47
  *
15
48
  * The selection is fixed when the builder is created and the filter is added by chaining, so the
16
49
  * result type is known before a single predicate is written. Create one with `client.select(...)`
17
- * rather than constructing it directly — that is what infers the entity shape from the selection.
50
+ * rather than constructing it directly — that is what infers the result shape from the selection.
51
+ *
52
+ * The query returns flat rows: the selected entity fields and attributes side by side. Naming a
53
+ * schema with {@link from} changes what the selection means for nothing but the selected
54
+ * attributes — they come back typed and checked as the schema declares — and has `where` checked
55
+ * against it, so that filtering on a column the schema does not index, or comparing one against the
56
+ * wrong type, is a compile error. A named schema also restricts the query to entities tagged with
57
+ * its name, and leaves that tag out of the rows' `$attributes` and `$attributeSchema`.
18
58
  *
19
59
  * Every filter added with {@link where}, {@link ownedBy} and {@link createdBy} must hold; use
20
60
  * {@link or} for alternatives inside one call.
21
61
  *
22
- * @typeParam TEntity - The projected entity shape, inferred from the selection by `client.select()`.
62
+ * @typeParam TSelection - The selection, inferred by `client.select()`.
63
+ * @typeParam TSchema - The schema named with {@link from}, if any.
23
64
  *
24
65
  * @example
25
66
  * import { createPublicClient } from "@arkiv-network/sdk"
26
- * import { i32 } from "@arkiv-network/sdk/attr"
67
+ * import { dec } from "@arkiv-network/sdk/schema"
27
68
  * import { tiramisu } from "@arkiv-network/sdk/chains"
28
- * import { eq, gte, or } from "@arkiv-network/sdk/query"
69
+ * import { and, eq, gt } from "@arkiv-network/sdk/query"
29
70
  * import { http } from "viem"
30
71
  *
31
72
  * const client = createPublicClient({ chain: tiramisu, transport: http() })
32
73
  *
33
74
  * const page = await client
34
- * .select({ key: true, attributes: true })
35
- * .where(gte("level", i32(10)), or(eq("status", "open"), eq("status", "review")))
36
- * .ownedBy(owner)
37
- * .limit(100)
75
+ * .select({ $key: true, author: true, title: true })
76
+ * .from(Posts)
77
+ * .where(and(eq("category", "docs"), gt("score", dec("4.0"))))
78
+ * .limit(20)
38
79
  * .fetch()
39
80
  *
40
- * for (const entity of page.entities) console.log(entity.key, entity.attributes)
81
+ * for (const row of page.entities) console.log(row.$key, row.title.value)
41
82
  */
42
- export class SelectQueryBuilder<TEntity = Entity> {
43
- private readonly _client: ArkivClient
44
- private readonly _select: RpcSelect
45
- private readonly _filters: Expression[] = []
46
- private _ownedBy: Expression | undefined
47
- private _createdBy: Expression | undefined
83
+ export class SelectQueryBuilder<
84
+ TSelection = "*",
85
+ TSchema extends Schema | undefined = undefined,
86
+ TCount extends bigint | undefined = undefined,
87
+ > extends QueryBuilderBase<TSchema> {
88
+ private readonly _selection: SelectArg | undefined
89
+ private _projection: Projection
90
+ private _onMismatch: "throw" | "skip" = "skip"
48
91
  private _limit: number | undefined
49
92
  private _cursor: string | undefined
50
- private _atBlock: bigint | undefined
93
+ private _withCount = false
51
94
 
95
+ /**
96
+ * @throws If the selection is not `"*"` or an object, asks for nothing, or names an attribute
97
+ * outside the name grammar.
98
+ */
52
99
  constructor(client: ArkivClient, selection?: SelectArg) {
53
- this._client = client
54
- this._select = toRpcSelect(selection)
100
+ super(client)
101
+ // A copy: the caller's object may change after this, and the builder is typed for it as it was.
102
+ this._selection = snapshotSelection(selection)
103
+ this._projection = projectSelection(this._selection)
55
104
  }
56
105
 
57
106
  /**
58
- * Adds filters. Everything passed here, and across repeated calls, must hold.
107
+ * Runs the query through a schema. The selection keeps its meaning and the rows their shape;
108
+ * each selected attribute becomes a column — typed as the schema declares, and checked against
109
+ * it — and `where` is checked against the schema's indexed columns.
59
110
  *
60
- * @param expressions - The expressions, as separate arguments or one array.
111
+ * A named schema also filters: only entities tagged with its name match, so a query through it
112
+ * sees only entities written through it, and needs no other filter. The tag is how the query finds
113
+ * them rather than one of their attributes, so rows leave it out of `$attributes` and
114
+ * `$attributeSchema`. An unnamed schema types the query without filtering it — `where` alone
115
+ * decides which entities match.
61
116
  *
62
- * @example
63
- * builder.where(eq("category", "docs"))
64
- * builder.where(gte("level", i32(10)), lt("level", i32(20)))
65
- * builder.where(or(eq("status", "open"), not(exists("closedAt"))))
66
- */
67
- where(expressions: readonly Expression[]): this
68
- where(...expressions: Expression[]): this
69
- where(...expressions: (Expression | readonly Expression[])[]): this {
70
- this._filters.push(...expressions.flat())
71
- return this
72
- }
73
-
74
- /**
75
- * Restricts the results to entities this account owns — shorthand for
76
- * `where(eq("$owner", addr(owner)))`. Calling it again replaces the filter.
117
+ * A matching entity that does not fit — a selected required column missing or of another type,
118
+ * or, for a named schema, a tag that does not match — is left out of the page, or fails the fetch
119
+ * with a {@link SchemaMismatchError} with `{ onMismatch: "throw" }`. Columns the selection leaves
120
+ * out are not checked.
77
121
  *
78
- * @example
79
- * builder.ownedBy("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
80
- */
81
- ownedBy(owner: Hex): this {
82
- this._ownedBy = eq("$owner", addr(owner))
83
- return this
84
- }
85
-
86
- /**
87
- * Restricts the results to entities this account created — shorthand for
88
- * `where(eq("$creator", addr(creator)))`. Calling it again replaces the filter.
122
+ * Returns a new builder, carrying whatever this one already holds; this one is left as it was.
123
+ *
124
+ * @param schema - The schema, built with `defineSchema`.
125
+ * @param options - How to treat entities that do not fit. {@link FromOptions}
126
+ * @throws {SchemaDefinitionError} If `schema` was not built with `defineSchema`.
127
+ * @throws If the selection names something that is neither an entity field nor a column.
89
128
  *
90
- * The creator never changes; the owner does, so these differ after a transfer.
129
+ * @example
130
+ * client.select({ $key: true, title: true }).from(Posts) // rows typed { $key; title }
131
+ * client.select().from(Posts) // every field and every column
132
+ * client.select().from(Posts, { onMismatch: "throw" }) // fail on entities that do not fit
91
133
  */
92
- createdBy(creator: Hex): this {
93
- this._createdBy = eq("$creator", addr(creator))
94
- return this
134
+ from<S extends Schema>(
135
+ schema: S & SelectionCheck<TSelection, S>,
136
+ options: FromOptions = {},
137
+ ): SelectQueryBuilder<TSelection, S, TCount> {
138
+ if (!isSchema(schema)) {
139
+ throw new SchemaDefinitionError("from() takes a schema built with defineSchema(...)")
140
+ }
141
+ const { onMismatch = "skip" } = options
142
+ if (onMismatch !== "throw" && onMismatch !== "skip") {
143
+ throw new TypeError(`onMismatch must be "throw" or "skip", got ${String(onMismatch)}`)
144
+ }
145
+ // Resolved here rather than at fetch time, so a selection naming something that is neither an
146
+ // entity field nor a column fails at the call that made it wrong.
147
+ const projection = projectSelection(this._selection, schema)
148
+ const typed = this.copy<SelectQueryBuilder<TSelection, S, TCount>>()
149
+ typed._schema = schema
150
+ typed._projection = projection
151
+ typed._onMismatch = onMismatch
152
+ return typed
95
153
  }
96
154
 
97
155
  /**
98
- * Sets the page size, up to the node maximum of 200. Without it the node picks the page size.
156
+ * Sets the page size, up to the node maximum of 100. Without it the node picks the page size.
99
157
  *
100
158
  * @example
101
159
  * builder.limit(100)
@@ -118,71 +176,89 @@ export class SelectQueryBuilder<TEntity = Entity> {
118
176
  }
119
177
 
120
178
  /**
121
- * Reads the state as of a given block rather than the head. The block must be within the range
122
- * the node retains.
179
+ * Also counts every entity the query matches, regardless of the page size. Each page then carries
180
+ * the total as {@link QueryResult.count}, typed `bigint`, so a paginated list can show it without
181
+ * a second query. To count without fetching rows, use `client.count()` instead.
123
182
  *
124
- * @example
125
- * builder.atBlock(1_297_000n)
126
- */
127
- atBlock(block: bigint): this {
128
- this._atBlock = block
129
- return this
130
- }
131
-
132
- /**
133
- * The query string this builder will send — the same text the node parses.
183
+ * The node counts what the filters match. Through a schema, that includes entities the pages
184
+ * leave out for not fitting it, so the rows of a whole walk can add up to less than the count:
185
+ * the difference is the sum of every page's {@link QueryResult.skipped}.
186
+ *
187
+ * @throws If, when fetched, the node returns no count — it predates the count projection.
188
+ *
189
+ * Returns a new builder, carrying whatever this one already holds; this one is left as it was.
134
190
  *
135
- * @throws {InvalidPredicateError} If no filter has been added.
191
+ * @example
192
+ * const page = await builder.withCount().limit(20).fetch()
193
+ * page.count // every match, not just these 20
136
194
  */
137
- toString(): string {
138
- const filters = [...this._filters]
139
- if (this._ownedBy) filters.push(this._ownedBy)
140
- if (this._createdBy) filters.push(this._createdBy)
141
- if (filters.length === 0) {
142
- throw new InvalidPredicateError(
143
- "A query needs at least one filter — the language has no spelling for 'match every " +
144
- 'entity". Add a where(...) predicate, or narrow by ownedBy(...) / createdBy(...).',
145
- )
146
- }
147
- return and(filters).toString()
195
+ withCount(): SelectQueryBuilder<TSelection, TSchema, bigint> {
196
+ const counted = this.copy<SelectQueryBuilder<TSelection, TSchema, bigint>>()
197
+ counted._withCount = true
198
+ return counted
148
199
  }
149
200
 
150
201
  /**
151
202
  * Runs the query and returns the first page.
152
203
  *
153
- * @throws {InvalidPredicateError} If no filter has been added.
204
+ * @throws {InvalidPredicateError} If no filter has been added, or a filter does not fit the
205
+ * schema.
206
+ * @throws If the {@link limit} is not an integer from 1 to 100.
207
+ * @throws {SchemaMismatchError} If, through a schema, a matching entity does not fit the selected
208
+ * columns or a named schema's tag, and `from()` was given `{ onMismatch: "throw" }`.
154
209
  * @throws {QueryError} If the node rejects the query.
155
210
  *
156
211
  * @example
157
- * const page = await client.select({ key: true }).where(eq("category", "docs")).fetch()
212
+ * const page = await client.select({ $key: true }).where(eq("category", "docs")).fetch()
158
213
  * page.entities // this page
159
214
  * await page.next() // the next one
160
215
  */
216
+ fetch(): Promise<QueryResult<ResultOf<TSelection, TSchema>, TCount>> {
217
+ return this.fetchFirstPage()
218
+ }
219
+
161
220
  // Async so that a builder with no filter rejects like every other failure here, rather than
162
221
  // throwing synchronously out of a call the caller is awaiting.
163
- async fetch(): Promise<QueryResult<TEntity>> {
222
+ private async fetchFirstPage(): Promise<QueryResult<ResultOf<TSelection, TSchema>, TCount>> {
164
223
  // Snapshot the request here, so the pages of one walk are all pages of the *same* query. The
165
224
  // builder stays mutable and reusable; a cursor is bound to the query, block and selection it
166
225
  // was issued for, so re-reading a since-edited builder would page with a mismatched cursor.
226
+ const projection = this._projection
167
227
  const request = Object.freeze({
168
228
  query: this.toString(),
169
- select: this._select,
229
+ select: projection.select,
170
230
  limit: this._limit,
171
231
  atBlock: this._atBlock,
232
+ count: this._withCount,
172
233
  })
173
- return fetchPage<TEntity>(this._client, request, this._cursor)
234
+ // Every entity is laid out as a row of the selection — and, through a schema, checked against it.
235
+ // The static narrowing to the selected fields comes from `client.select()`'s inference and
236
+ // nothing at runtime depends on it.
237
+ const skip = this._onMismatch === "skip"
238
+ const decode = ((entity: Entity) => {
239
+ try {
240
+ return toRow(entity, projection)
241
+ } catch (error) {
242
+ if (skip && error instanceof SchemaMismatchError) return SKIPPED
243
+ throw error
244
+ }
245
+ }) as (entity: Entity) => ResultOf<TSelection, TSchema> | typeof SKIPPED
246
+ return fetchPage(this._client, request, this._cursor, decode) as Promise<
247
+ QueryResult<ResultOf<TSelection, TSchema>, TCount>
248
+ >
174
249
  }
175
250
 
176
251
  /**
177
- * Walks every page, yielding one entity at a time.
252
+ * Walks every page, yielding one row at a time.
178
253
  *
179
254
  * @example
180
- * for await (const entity of client.select({ key: true }).where(eq("category", "docs"))) {
181
- * console.log(entity.key)
255
+ * for await (const row of client.select({ $key: true }).where(eq("category", "docs"))) {
256
+ * console.log(row.$key)
182
257
  * }
183
258
  */
184
- async *[Symbol.asyncIterator](): AsyncGenerator<TEntity> {
185
- let page: QueryResult<TEntity> | undefined = await this.fetch()
259
+ async *[Symbol.asyncIterator](): AsyncGenerator<ResultOf<TSelection, TSchema>> {
260
+ let page: QueryResult<ResultOf<TSelection, TSchema>, TCount> | undefined =
261
+ await this.fetchFirstPage()
186
262
  while (page) {
187
263
  yield* page.entities
188
264
  page = page.hasNextPage() ? await page.next() : undefined
@@ -190,25 +266,42 @@ export class SelectQueryBuilder<TEntity = Entity> {
190
266
  }
191
267
  }
192
268
 
269
+ /** What `decode` returns for an entity `{ onMismatch: "skip" }` leaves out of its page. */
270
+ const SKIPPED: unique symbol = Symbol("skipped")
271
+
193
272
  /**
194
273
  * Fetches one page and wires up the next.
195
274
  *
196
275
  * Free-standing rather than a method: a page holds this closure for its lifetime, and closing over
197
276
  * a frozen request keeps the builder — and everything it references — out of that reference.
198
277
  */
199
- async function fetchPage<TEntity>(
278
+ async function fetchPage<TResult>(
200
279
  client: ArkivClient,
201
280
  request: Readonly<Omit<QueryRequest, "cursor">>,
202
281
  cursor: string | undefined,
203
- ): Promise<QueryResult<TEntity>> {
282
+ decode: (entity: Entity) => TResult | typeof SKIPPED,
283
+ ): Promise<QueryResult<TResult>> {
204
284
  const response = await runQuery(client, { ...request, cursor })
285
+ // withCount() types the count as present, so a node that leaves it out is an error, not a page
286
+ // whose count is silently undefined.
287
+ if (request.count && response.count === undefined) {
288
+ throw new Error("The node returned no count. It may predate the count projection.")
289
+ }
290
+
291
+ const results: TResult[] = []
292
+ let skipped = 0
293
+ for (const entity of response.entities) {
294
+ const result = decode(entity)
295
+ if (result === SKIPPED) skipped++
296
+ else results.push(result)
297
+ }
205
298
 
206
- return new QueryResult<TEntity>(
207
- // Every row decodes to a full Entity; the static narrowing to the selected fields comes from
208
- // `client.select()`'s inference and nothing at runtime depends on it.
209
- response.entities as unknown as TEntity[],
299
+ return new QueryResult<TResult>(
300
+ results,
210
301
  response.blockNumber,
211
302
  response.cursor,
212
- (next) => fetchPage<TEntity>(client, request, next),
303
+ skipped,
304
+ response.count,
305
+ (next) => fetchPage(client, request, next, decode),
213
306
  )
214
307
  }
@@ -1,8 +1,9 @@
1
1
  import { NoMoreResultsError } from "../errors"
2
- import type { Entity } from "../types/entity"
3
2
 
4
3
  /** Fetches the page a cursor points at. */
5
- type FetchPage<TEntity> = (cursor: string) => Promise<QueryResult<TEntity>>
4
+ type FetchPage<TEntity, TCount extends bigint | undefined> = (
5
+ cursor: string,
6
+ ) => Promise<QueryResult<TEntity, TCount>>
6
7
 
7
8
  /**
8
9
  * One page of query results.
@@ -10,16 +11,21 @@ type FetchPage<TEntity> = (cursor: string) => Promise<QueryResult<TEntity>>
10
11
  * A page is immutable: {@link next} returns the following page rather than mutating this one, so a
11
12
  * page that has been read stays readable.
12
13
  *
13
- * @typeParam TEntity - The shape of each entity, inferred from the selection by `client.select()`.
14
+ * @typeParam TEntity - The shape of each row, inferred from the selection by `client.select()`.
15
+ * @typeParam TCount - `bigint` when the query was built with `withCount()`, and `undefined` when it
16
+ * was not.
14
17
  *
15
18
  * @example
16
- * let page = await client.select({ key: true }).where(eq("category", "docs")).limit(100).fetch()
19
+ * let page = await client.select({ $key: true }).where(eq("category", "docs")).limit(100).fetch()
17
20
  * while (page.hasNextPage()) {
18
21
  * page = await page.next()
19
22
  * }
20
23
  */
21
- export class QueryResult<TEntity = Entity> {
22
- /** The entities on this page. */
24
+ export class QueryResult<
25
+ TEntity = Record<string, unknown>,
26
+ TCount extends bigint | undefined = bigint | undefined,
27
+ > {
28
+ /** The rows on this page — flat, as the selection lays them out. */
23
29
  readonly entities: readonly TEntity[]
24
30
  /**
25
31
  * The block this page was read at.
@@ -30,18 +36,38 @@ export class QueryResult<TEntity = Entity> {
30
36
  readonly blockNumber: bigint
31
37
  /** The cursor for the next page, or `undefined` when this was the last one. */
32
38
  readonly cursor: string | undefined
39
+ /**
40
+ * How many matching entities this page left out because they did not fit the schema — always
41
+ * `0` without a schema, or with `.from(schema, { onMismatch: "throw" })`.
42
+ *
43
+ * Counted per page, not per walk: `entities.length + skipped` is what the node returned for this
44
+ * page, so a short or empty page with a cursor is explained here rather than mistaken for the
45
+ * end of the results.
46
+ */
47
+ readonly skipped: number
48
+ /**
49
+ * How many entities the query matches in all, regardless of the page size — a `bigint` when the
50
+ * query was built with `withCount()`, and `undefined` otherwise. Every page of one walk reads the
51
+ * same block, so every page carries the same count. It includes entities left out for not fitting
52
+ * the schema, which {@link skipped} reports per page.
53
+ */
54
+ readonly count: TCount
33
55
 
34
- private readonly fetchPage: FetchPage<TEntity>
56
+ private readonly fetchPage: FetchPage<TEntity, TCount>
35
57
 
36
58
  constructor(
37
59
  entities: readonly TEntity[],
38
60
  blockNumber: bigint,
39
61
  cursor: string | undefined,
40
- fetchPage: FetchPage<TEntity>,
62
+ skipped: number,
63
+ count: TCount,
64
+ fetchPage: FetchPage<TEntity, TCount>,
41
65
  ) {
42
66
  this.entities = entities
43
67
  this.blockNumber = blockNumber
44
68
  this.cursor = cursor
69
+ this.skipped = skipped
70
+ this.count = count
45
71
  this.fetchPage = fetchPage
46
72
  }
47
73
 
@@ -64,7 +90,7 @@ export class QueryResult<TEntity = Entity> {
64
90
  */
65
91
  // Async so that "no more pages" arrives as a rejection like every other failure here, rather
66
92
  // than as a synchronous throw the caller's `.catch` would miss.
67
- async next(): Promise<QueryResult<TEntity>> {
93
+ async next(): Promise<QueryResult<TEntity, TCount>> {
68
94
  if (this.cursor === undefined) {
69
95
  throw new NoMoreResultsError()
70
96
  }