@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
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { hexToString } from "viem"
3
+ import { encodeCreate, encodePatch, MAX_ENTITY_BYTES } from "./attributes"
4
+ import {
5
+ ConflictingMutationError,
6
+ EntityTooLargeError,
7
+ InvalidAttributeNameError,
8
+ InvalidValueError,
9
+ MissingValueError,
10
+ SchemaDefinitionError,
11
+ SchemaMismatchError,
12
+ } from "./errors"
13
+ import { isValidAttributeName, validateAttributeName } from "./names"
14
+ import { defineSchema, type Schema } from "./schema"
15
+ import { INDEXED_FLAG, TYPE_IDS } from "./types"
16
+ import { bytes, dec, i32, str, u64 } from "./values"
17
+
18
+ const Posts = defineSchema({
19
+ category: str().indexed(),
20
+ score: dec().indexed().optional(),
21
+ title: str(),
22
+ body: bytes(),
23
+ thumbnail: bytes().optional(),
24
+ mimeType: str().default("text/markdown"),
25
+ })
26
+
27
+ /** The encoded attributes keyed by their decoded name, for asserting on one at a time. */
28
+ function byName(attributes: ReturnType<typeof encodeCreate>) {
29
+ return Object.fromEntries(
30
+ attributes.map((attribute) => [hexToString(attribute.name, { size: 32 }), attribute]),
31
+ )
32
+ }
33
+
34
+ describe("encodeCreate", () => {
35
+ const values = { category: "docs", title: "Hello", body: new Uint8Array([1, 2]) }
36
+
37
+ it("flags indexed columns in bit 7 of the typeId, and leaves the rest unflagged", () => {
38
+ const encoded = byName(encodeCreate(Posts, { ...values, score: dec("4.1") }))
39
+ expect(encoded.category?.typeId).toBe(TYPE_IDS.str | INDEXED_FLAG)
40
+ expect(encoded.score?.typeId).toBe(TYPE_IDS.dec | INDEXED_FLAG)
41
+ expect(encoded.title?.typeId).toBe(TYPE_IDS.str)
42
+ expect(encoded.body?.typeId).toBe(TYPE_IDS.bytes)
43
+ })
44
+
45
+ it("writes a defaulted column left out with its default", () => {
46
+ const encoded = byName(encodeCreate(Posts, values))
47
+ expect(hexToString(encoded.mimeType?.value ?? "0x")).toBe("text/markdown")
48
+ })
49
+
50
+ it("prefers a given value over the default", () => {
51
+ const encoded = byName(encodeCreate(Posts, { ...values, mimeType: "text/plain" }))
52
+ expect(hexToString(encoded.mimeType?.value ?? "0x")).toBe("text/plain")
53
+ })
54
+
55
+ it("leaves out an optional column that is not given, or given as undefined", () => {
56
+ expect(byName(encodeCreate(Posts, values)).thumbnail).toBeUndefined()
57
+ expect(
58
+ byName(encodeCreate(Posts, { ...values, thumbnail: undefined })).thumbnail,
59
+ ).toBeUndefined()
60
+ })
61
+
62
+ it("carries any number of bytes columns", () => {
63
+ const encoded = byName(encodeCreate(Posts, { ...values, thumbnail: bytes("0xffd8") }))
64
+ expect(encoded.body?.value).toBe("0x0102")
65
+ expect(encoded.thumbnail?.value).toBe("0xffd8")
66
+ })
67
+
68
+ it("sorts strictly ascending by the encoded name, whatever order the schema declares", () => {
69
+ const names = encodeCreate(Posts, values).map((attribute) => attribute.name)
70
+ expect(names).toEqual([...names].sort())
71
+ })
72
+
73
+ it("rejects a required column left out, naming it", () => {
74
+ const { title: _, ...withoutTitle } = values
75
+ expect(() => encodeCreate(Posts, withoutTitle)).toThrow(SchemaMismatchError)
76
+ expect(() => encodeCreate(Posts, withoutTitle)).toThrow(/"title".*required str column/)
77
+ })
78
+
79
+ it("rejects a column the schema does not have", () => {
80
+ expect(() => encodeCreate(Posts, { ...values, extra: 1 })).toThrow(/"extra".*does not have/)
81
+ })
82
+
83
+ it("rejects a value of another type than its column, bare or tagged", () => {
84
+ // A bare string is a str, never a dec — the schema does not reinterpret it.
85
+ expect(() => encodeCreate(Posts, { ...values, score: "4.1" })).toThrow(
86
+ /"score": a dec column, but the value is a str/,
87
+ )
88
+ expect(() => encodeCreate(Posts, { ...values, title: i32(1) })).toThrow(SchemaMismatchError)
89
+ })
90
+
91
+ it("rejects null rather than treating it as absent", () => {
92
+ expect(() => encodeCreate(Posts, { ...values, thumbnail: null })).toThrow(MissingValueError)
93
+ })
94
+
95
+ it("rejects a column where a value belongs", () => {
96
+ expect(() => encodeCreate(Posts, { ...values, title: str() })).toThrow(/a column, not a value/)
97
+ })
98
+
99
+ it("rejects something that is not a schema", () => {
100
+ expect(() => encodeCreate({ columns: {} } as unknown as Schema, {})).toThrow(
101
+ SchemaDefinitionError,
102
+ )
103
+ })
104
+
105
+ it("accepts an empty schema, which writes no attributes", () => {
106
+ expect(encodeCreate(defineSchema({}), {})).toEqual([])
107
+ })
108
+ })
109
+
110
+ describe("a named schema's tag", () => {
111
+ const Tagged = defineSchema("posts", { title: str() })
112
+
113
+ it("is written on every create, as an indexed str", () => {
114
+ const encoded = byName(encodeCreate(Tagged, { title: "Hi" }))
115
+ expect(encoded["arkiv.schema"]?.typeId).toBe(TYPE_IDS.str | INDEXED_FLAG)
116
+ expect(hexToString(encoded["arkiv.schema"]?.value ?? "0x")).toBe("posts")
117
+ })
118
+
119
+ it("is not written by an unnamed schema", () => {
120
+ expect(
121
+ byName(encodeCreate(Posts, { category: "c", title: "t", body: new Uint8Array() }))[
122
+ "arkiv.schema"
123
+ ],
124
+ ).toBeUndefined()
125
+ })
126
+
127
+ it("is not touched by a patch, which changes only what it names", () => {
128
+ expect(byName(encodePatch(Tagged, { set: { title: "Hi" } }))["arkiv.schema"]).toBeUndefined()
129
+ })
130
+
131
+ it("cannot be written or unset as a column", () => {
132
+ expect(() => encodeCreate(Tagged, { title: "Hi", "arkiv.schema": "other" })).toThrow(
133
+ SchemaMismatchError,
134
+ )
135
+ expect(() => encodePatch(Tagged, { unset: ["arkiv.schema"] as never })).toThrow(
136
+ SchemaMismatchError,
137
+ )
138
+ })
139
+ })
140
+
141
+ describe("encodePatch", () => {
142
+ it("writes only what it names, with each column's indexed flag", () => {
143
+ const encoded = byName(encodePatch(Posts, { set: { category: "news", title: "Hi" } }))
144
+ expect(Object.keys(encoded).sort()).toEqual(["category", "title"])
145
+ expect(encoded.category?.typeId).toBe(TYPE_IDS.str | INDEXED_FLAG)
146
+ expect(encoded.title?.typeId).toBe(TYPE_IDS.str)
147
+ })
148
+
149
+ it("does not apply defaults — a patch changes only what it names", () => {
150
+ expect(byName(encodePatch(Posts, { set: { title: "Hi" } })).mimeType).toBeUndefined()
151
+ })
152
+
153
+ it("tombstones an optional column", () => {
154
+ const [tombstone] = encodePatch(Posts, { unset: ["thumbnail"] })
155
+ expect(tombstone).toMatchObject({ typeId: 0, value: "0x" })
156
+ })
157
+
158
+ it("refuses to unset a required column", () => {
159
+ expect(() => encodePatch(Posts, { unset: ["title"] as never })).toThrow(
160
+ /"title": a required column/,
161
+ )
162
+ })
163
+
164
+ it("refuses to unset or set a column the schema does not have", () => {
165
+ expect(() => encodePatch(Posts, { unset: ["nope"] as never })).toThrow(SchemaMismatchError)
166
+ expect(() => encodePatch(Posts, { set: { nope: 1 } })).toThrow(SchemaMismatchError)
167
+ })
168
+
169
+ it("rejects a name that is both set and unset", () => {
170
+ expect(() =>
171
+ encodePatch(Posts, { set: { thumbnail: new Uint8Array() }, unset: ["thumbnail"] }),
172
+ ).toThrow(ConflictingMutationError)
173
+ })
174
+
175
+ it("rejects a set value of the wrong type", () => {
176
+ expect(() => encodePatch(Posts, { set: { score: 4 } })).toThrow(SchemaMismatchError)
177
+ })
178
+
179
+ it("sorts writes and tombstones together", () => {
180
+ const names = encodePatch(Posts, { set: { title: "t", category: "c" }, unset: ["score"] }).map(
181
+ (attribute) => attribute.name,
182
+ )
183
+ expect(names).toEqual([...names].sort())
184
+ })
185
+
186
+ it("rejects an unset that is not an array", () => {
187
+ expect(() => encodePatch(Posts, { unset: "thumbnail" as never })).toThrow(TypeError)
188
+ })
189
+ })
190
+
191
+ describe("bytes", () => {
192
+ it("is never an indexed column's type", () => {
193
+ expect(() =>
194
+ encodeCreate(defineSchema({ n: u64().indexed() }), { n: bytes("0x01") as never }),
195
+ ).toThrow(/a u64 column, but the value is a bytes/)
196
+ })
197
+
198
+ it("rejects a value over the 128 KiB cap", () => {
199
+ expect(() =>
200
+ encodeCreate(Posts, {
201
+ category: "c",
202
+ title: "t",
203
+ body: new Uint8Array(128 * 1024 + 1),
204
+ }),
205
+ ).toThrow(InvalidValueError)
206
+ })
207
+ })
208
+
209
+ describe("the total bytes cap", () => {
210
+ const half = new Uint8Array(MAX_ENTITY_BYTES / 2)
211
+ const base = { category: "docs", title: "Hello" }
212
+
213
+ it("accepts bytes columns that add up to the cap", () => {
214
+ expect(() => encodeCreate(Posts, { ...base, body: half, thumbnail: half })).not.toThrow()
215
+ })
216
+
217
+ it("rejects a create whose bytes columns add up to more", () => {
218
+ const over = new Uint8Array(MAX_ENTITY_BYTES / 2 + 1)
219
+ expect(() => encodeCreate(Posts, { ...base, body: half, thumbnail: over })).toThrow(
220
+ EntityTooLargeError,
221
+ )
222
+ })
223
+
224
+ it("applies to a patch's writes too", () => {
225
+ const over = new Uint8Array(MAX_ENTITY_BYTES / 2 + 1)
226
+ expect(() => encodePatch(Posts, { set: { body: half, thumbnail: over } })).toThrow(
227
+ /at most 131072 bytes in total, got 131073/,
228
+ )
229
+ })
230
+ })
231
+
232
+ describe("validateAttributeName", () => {
233
+ it("accepts the Ident32 grammar, in either case", () => {
234
+ for (const name of ["a", "tag", "Tag", "projectId", "num_attr", "a.b-c_D9", "a".repeat(32)]) {
235
+ expect(isValidAttributeName(name)).toBe(true)
236
+ }
237
+ })
238
+
239
+ it("treats names as case-sensitive, so two casings are two attributes", () => {
240
+ const schema = defineSchema({ level: i32(), Level: i32() })
241
+ expect(encodeCreate(schema, { level: 1, Level: 2 })).toHaveLength(2)
242
+ })
243
+
244
+ it("rejects names outside the grammar, pointing at the offending character", () => {
245
+ expect(() => validateAttributeName("")).toThrow(InvalidAttributeNameError)
246
+ expect(() => validateAttributeName("1tag")).toThrow(/starts with "1"/)
247
+ expect(() => validateAttributeName("_tag")).toThrow(/starts with "_"/)
248
+ expect(() => validateAttributeName("has space")).toThrow(/" " at position 3/)
249
+ expect(() => validateAttributeName("héllo")).toThrow(/"é" at position 1/)
250
+ expect(() => validateAttributeName("a:b")).toThrow(/":" at position 1/)
251
+ expect(() => validateAttributeName("a".repeat(33))).toThrow(/33 bytes long/)
252
+ })
253
+
254
+ it("reserves $ for the system attributes the engine sets", () => {
255
+ expect(() => validateAttributeName("$owner")).toThrow(/reserved for system attributes/)
256
+ })
257
+
258
+ it("rejects names the query language could never parse back, whatever their casing", () => {
259
+ for (const reserved of ["and", "OR", "Between", "key", "str", "bytes", "EXISTS", "Bytes32"]) {
260
+ expect(() => validateAttributeName(reserved)).toThrow(/reserved by the query language/)
261
+ }
262
+ // Only the exact word is reserved — a name containing it is fine.
263
+ expect(isValidAttributeName("android")).toBe(true)
264
+ expect(isValidAttributeName("sortKey")).toBe(true)
265
+ })
266
+ })
@@ -0,0 +1,212 @@
1
+ import { type AbiAttribute, encodeAbiAttribute, encodeTombstone } from "./codec"
2
+ import type { AnyColumn } from "./column"
3
+ import {
4
+ ConflictingMutationError,
5
+ EntityTooLargeError,
6
+ SchemaDefinitionError,
7
+ SchemaMismatchError,
8
+ } from "./errors"
9
+ import { describeColumns, isSchema, lookupColumn, SCHEMA_ATTRIBUTE, type Schema } from "./schema"
10
+ import { type ArkivValue, INDEXED_FLAG, TYPE_IDS, type TypeTag } from "./types"
11
+ import { toValue, type ValueInput } from "./values"
12
+
13
+ /**
14
+ * The most data the `bytes` columns of one create or patch may hold together, in bytes. Each
15
+ * `bytes` value is also capped on its own, at `MAX_BYTES_LENGTH`.
16
+ *
17
+ * Provisional: the protocol has not settled a per-entity cap yet, so this keeps the budget the
18
+ * single payload had — and an operation much larger than it would not fit in a transaction the
19
+ * node accepts anyway.
20
+ */
21
+ export const MAX_ENTITY_BYTES = 128 * 1024
22
+
23
+ /**
24
+ * An entity's attributes as a whole — what the `$attributes` selection returns — keyed by name,
25
+ * every value typed.
26
+ *
27
+ * The vocabulary is the same in both directions, so a value read from here drops straight back into
28
+ * a write — as long as its name is a column of the schema the write goes through.
29
+ */
30
+ export type Attributes = Readonly<Record<string, ArkivValue>>
31
+
32
+ /**
33
+ * The names, types and indexing of an entity's attributes, with no values — what the
34
+ * `$attributeSchema` selection returns. `indexed` is `undefined` when the node did not say.
35
+ *
36
+ * It is for finding out what entities hold before fetching their values. Values can be large — a
37
+ * `bytes` attribute holds up to 128 KiB — and an RPC endpoint may cap how big a response it sends,
38
+ * so a page of 100 entities with every attribute can be more than it will return. The schema is a
39
+ * few bytes per attribute: select it for a page, then fetch the attributes you need by name, or the
40
+ * large ones a few entities at a time.
41
+ *
42
+ * It also tells apart two entities that use the same name with different types, and shows which
43
+ * attributes are indexed, and so can be filtered on.
44
+ */
45
+ export type AttributeSchema = Readonly<
46
+ Record<string, { type: TypeTag; indexed: boolean | undefined }>
47
+ >
48
+
49
+ /**
50
+ * Encodes a create's values into the ABI attribute array it carries: every column the
51
+ * values name, plus the default of every defaulted column they leave out, each flagged indexed or
52
+ * not as its column says — and, for a named schema, the indexed tag that marks the entity as its.
53
+ *
54
+ * @throws {SchemaDefinitionError} If `schema` is not a schema built with `defineSchema`.
55
+ * @throws {TypeError} If `values` is not an object.
56
+ * @throws {SchemaMismatchError} If a value names a column the schema lacks, a required column is
57
+ * left out, or a value's type is not its column's.
58
+ * @throws {MissingValueError} If a value is `null`.
59
+ * @throws {UntypedValueError} If a value is neither a tagged value nor a bare form.
60
+ * @throws {InvalidValueError} If a value does not fit its type.
61
+ * @throws {EntityTooLargeError} If the `bytes` values add up to more than {@link MAX_ENTITY_BYTES}.
62
+ */
63
+ export function encodeCreate(
64
+ schema: Schema,
65
+ values: Readonly<Record<string, unknown>>,
66
+ ): AbiAttribute[] {
67
+ assertSchema(schema)
68
+ for (const name of keysOf(values, "create")) columnFor(schema, name, "create")
69
+
70
+ const attributes: AbiAttribute[] = []
71
+ if (schema.tag !== undefined) {
72
+ attributes.push(encodeAbiAttribute(SCHEMA_ATTRIBUTE, schema.tag, true))
73
+ }
74
+ for (const [name, column] of Object.entries(schema.columns)) {
75
+ const input = Object.hasOwn(values, name) ? values[name] : undefined
76
+ if (input === undefined) {
77
+ if (column.defaultValue !== undefined) {
78
+ attributes.push(encodeAbiAttribute(name, column.defaultValue, column.isIndexed))
79
+ } else if (!column.isOptional) {
80
+ throw new SchemaMismatchError(
81
+ name,
82
+ `the create leaves out a required ${column.type} column. Give it a value, or declare ` +
83
+ `it ${column.type}().optional() or with a .default(...)`,
84
+ )
85
+ }
86
+ continue
87
+ }
88
+ attributes.push(encodeColumn(name, column, input))
89
+ }
90
+ assertBytesFit(attributes)
91
+ return attributes.sort(byName)
92
+ }
93
+
94
+ /**
95
+ * Encodes a patch into the ABI attribute array it carries: a write for each column in `set`, with
96
+ * the indexed flag its column declares, and a tombstone for each name in `unset`. A column `set`
97
+ * to `undefined` is left out, as it is from a create.
98
+ *
99
+ * Writing a column with a different indexed flag than the entity holds flips its indexing, so a
100
+ * schema that marks a column `.indexed()` indexes it from the next patch that writes it.
101
+ *
102
+ * @throws {SchemaDefinitionError} If `schema` is not a schema built with `defineSchema`.
103
+ * @throws {SchemaMismatchError} If a name is not a column, a value's type is not its column's, or
104
+ * `unset` names a required column.
105
+ * @throws {ConflictingMutationError} If a name appears in both `set` and `unset`.
106
+ * @throws {MissingValueError} If a value in `set` is `null`.
107
+ * @throws {UntypedValueError} If a value in `set` is neither a tagged value nor a bare form.
108
+ * @throws {InvalidValueError} If a value does not fit its type.
109
+ * @throws {EntityTooLargeError} If the `bytes` values in `set` add up to more than
110
+ * {@link MAX_ENTITY_BYTES}.
111
+ * @throws {TypeError} If `set` is not an object, or `unset` is not an array.
112
+ */
113
+ export function encodePatch(
114
+ schema: Schema,
115
+ {
116
+ set = {},
117
+ unset = [],
118
+ }: {
119
+ set?: Readonly<Record<string, unknown>> | undefined
120
+ unset?: readonly string[] | undefined
121
+ },
122
+ ): AbiAttribute[] {
123
+ assertSchema(schema)
124
+
125
+ const mutations: AbiAttribute[] = []
126
+ for (const name of keysOf(set, "patch")) {
127
+ const column = columnFor(schema, name, "patch")
128
+ const input = set[name]
129
+ if (input !== undefined) mutations.push(encodeColumn(name, column, input))
130
+ }
131
+
132
+ if (!Array.isArray(unset)) {
133
+ throw new TypeError(`"unset" must be an array of column names`)
134
+ }
135
+ for (const name of new Set(unset)) {
136
+ const column = columnFor(schema, name, "patch")
137
+ if (!column.isOptional) {
138
+ throw new SchemaMismatchError(
139
+ name,
140
+ "a required column, so unsetting it would leave the entity without a value its schema " +
141
+ `requires. Declare it ${column.type}().optional() to allow that`,
142
+ )
143
+ }
144
+ if (Object.hasOwn(set, name) && set[name] !== undefined) {
145
+ throw new ConflictingMutationError(name)
146
+ }
147
+ mutations.push(encodeTombstone(name))
148
+ }
149
+ assertBytesFit(mutations)
150
+ return mutations.sort(byName)
151
+ }
152
+
153
+ function assertSchema(schema: Schema): void {
154
+ if (!isSchema(schema)) {
155
+ throw new SchemaDefinitionError(
156
+ "expected a schema built with defineSchema(...) — writes are typed by the schema they name",
157
+ )
158
+ }
159
+ }
160
+
161
+ function keysOf(
162
+ values: Readonly<Record<string, unknown>>,
163
+ operation: "create" | "patch",
164
+ ): string[] {
165
+ if (typeof values !== "object" || values === null) {
166
+ throw new TypeError(`The ${operation} values must be an object keyed by column name`)
167
+ }
168
+ return Object.keys(values)
169
+ }
170
+
171
+ function columnFor(schema: Schema, name: string, operation: "create" | "patch"): AnyColumn {
172
+ const column = lookupColumn(schema, name)
173
+ if (column === undefined) {
174
+ throw new SchemaMismatchError(
175
+ name,
176
+ `the ${operation} names a column this schema does not have. Its columns are ` +
177
+ describeColumns(schema),
178
+ )
179
+ }
180
+ return column
181
+ }
182
+
183
+ /** One column's value, resolved, checked against the column's type, and encoded. */
184
+ function encodeColumn(name: string, column: AnyColumn, input: unknown): AbiAttribute {
185
+ const value = toValue(input as ValueInput, name)
186
+ if (value.type !== column.type) {
187
+ throw new SchemaMismatchError(
188
+ name,
189
+ `a ${column.type} column, but the value is a ${value.type}. Write ${column.type}(...) ` +
190
+ "around the value",
191
+ )
192
+ }
193
+ return encodeAbiAttribute(name, value, column.isIndexed)
194
+ }
195
+
196
+ /** Throws if the `bytes` values among `attributes` add up to more than {@link MAX_ENTITY_BYTES}. */
197
+ function assertBytesFit(attributes: readonly AbiAttribute[]): void {
198
+ let total = 0
199
+ for (const { typeId, value } of attributes) {
200
+ if ((typeId & ~INDEXED_FLAG) === TYPE_IDS.bytes) total += (value.length - 2) / 2
201
+ }
202
+ if (total > MAX_ENTITY_BYTES) throw new EntityTooLargeError(total, MAX_ENTITY_BYTES)
203
+ }
204
+
205
+ /**
206
+ * The engine requires attributes **strictly ascending by their `bytes32` name**: that ordering is
207
+ * what the canonical serialization hashes into the state root, and it doubles as the uniqueness
208
+ * check.
209
+ */
210
+ function byName(a: AbiAttribute, b: AbiAttribute): number {
211
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
212
+ }
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"
2
2
  import type { Hex } from "viem"
3
3
  import {
4
4
  decodeRpcValue,
5
+ decodeTypeId,
5
6
  decodeValueBytes,
6
7
  encodeAbiAttribute,
7
8
  encodeTombstone,
@@ -9,13 +10,13 @@ import {
9
10
  } from "./codec"
10
11
  import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
11
12
  import type { ArkivValue } from "./types"
12
- import { addr, bool, bytes32, dec, i32, key, str, u64, u256 } from "./values"
13
+ import { addr, bool, bytes, bytes32, dec, i32, key, str, u64, u256 } from "./values"
13
14
 
14
15
  const ZERO = `0x${"00".repeat(32)}` as Hex
15
16
  const KEY = `0x${"ab".repeat(32)}` as Hex
16
17
  const VITALIK = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
17
18
 
18
- /** One value of every user-settable type, including the awkward edges of each. */
19
+ /** One value of every type, including the awkward edges of each. */
19
20
  const SAMPLES: ArkivValue[] = [
20
21
  bool(true),
21
22
  bool(false),
@@ -37,6 +38,8 @@ const SAMPLES: ArkivValue[] = [
37
38
  str(""),
38
39
  str("hello"),
39
40
  str("é".repeat(64)),
41
+ bytes(new Uint8Array()),
42
+ bytes("0xdeadbeef"),
40
43
  ]
41
44
 
42
45
  describe("ABI value encoding", () => {
@@ -50,7 +53,7 @@ describe("ABI value encoding", () => {
50
53
 
51
54
  it("gives every word type exactly 32 bytes", () => {
52
55
  for (const value of SAMPLES) {
53
- if (value.type === "str") continue
56
+ if (value.type === "str" || value.type === "bytes") continue
54
57
  expect(encodeValueBytes(value)).toHaveLength(2 + 64)
55
58
  }
56
59
  })
@@ -83,6 +86,11 @@ describe("ABI value encoding", () => {
83
86
  expect(encodeValueBytes(str("é"))).toBe("0xc3a9")
84
87
  })
85
88
 
89
+ it("gives bytes their raw bytes, with no padding", () => {
90
+ expect(encodeValueBytes(bytes("0xdeadbeef"))).toBe("0xdeadbeef")
91
+ expect(encodeValueBytes(bytes(new Uint8Array()))).toBe("0x")
92
+ })
93
+
86
94
  it("rejects a word a well-formed encoder would not have produced", () => {
87
95
  expect(() => decodeValueBytes("bool", `0x${"00".repeat(31)}02`)).toThrow(/exactly 0 or 1/)
88
96
  expect(() => decodeValueBytes("u256", "0x2a")).toThrow(/must be 32 bytes, got 1/)
@@ -97,12 +105,23 @@ describe("ABI value encoding", () => {
97
105
 
98
106
  describe("encodeAbiAttribute", () => {
99
107
  it("packs the name left-aligned into its bytes32 cell", () => {
100
- const attribute = encodeAbiAttribute("level", i32(10))
108
+ const attribute = encodeAbiAttribute("level", i32(10), false)
101
109
  // "level" is 6c6576656c, null-padded to 32 bytes.
102
110
  expect(attribute.name).toBe(`0x6c6576656c${"00".repeat(27)}`)
103
111
  expect(attribute.typeId).toBe(2)
104
112
  })
105
113
 
114
+ it("sets bit 7 of the typeId for an indexed attribute, and only that bit", () => {
115
+ expect(encodeAbiAttribute("level", i32(10), true).typeId).toBe(0x82)
116
+ expect(encodeAbiAttribute("tag", str("x"), true).typeId).toBe(0x88)
117
+ expect(encodeAbiAttribute("tag", str("x"), false).typeId).toBe(0x08)
118
+ })
119
+
120
+ it("refuses to index bytes, which the engine would revert", () => {
121
+ expect(() => encodeAbiAttribute("body", bytes("0x01"), true)).toThrow(/never indexed/)
122
+ expect(encodeAbiAttribute("body", bytes("0x01"), false).typeId).toBe(7)
123
+ })
124
+
106
125
  it("encodes a tombstone as a zero-length value under typeId 0", () => {
107
126
  expect(encodeTombstone("flagged")).toEqual({
108
127
  name: `0x666c6167676564${"00".repeat(25)}`,
@@ -112,6 +131,19 @@ describe("encodeAbiAttribute", () => {
112
131
  })
113
132
  })
114
133
 
134
+ describe("decodeTypeId", () => {
135
+ it("splits the type from the indexed flag", () => {
136
+ expect(decodeTypeId(0x88)).toEqual({ tag: "str", indexed: true })
137
+ expect(decodeTypeId(0x07)).toEqual({ tag: "bytes", indexed: false })
138
+ expect(decodeTypeId(0x87)).toEqual({ tag: "bytes", indexed: true })
139
+ })
140
+
141
+ it("rejects a type it does not know, flagged or not", () => {
142
+ expect(() => decodeTypeId(0x7f)).toThrow(UnknownAttributeTypeError)
143
+ expect(() => decodeTypeId(0x80)).toThrow(UnknownAttributeTypeError)
144
+ })
145
+ })
146
+
115
147
  describe("decodeRpcValue", () => {
116
148
  it("reads the JSON encoding each type declares", () => {
117
149
  expect(decodeRpcValue("bool", true).value).toBe(true)
@@ -133,7 +165,7 @@ describe("decodeRpcValue", () => {
133
165
  expect(decodeRpcValue("i32", "-42").value).toBe(-42)
134
166
  })
135
167
 
136
- it("decodes the system-only bytes type, which only ever arrives as a payload", () => {
168
+ it("decodes bytes as lowercase hex", () => {
137
169
  expect(decodeRpcValue("bytes", "0xDEADBEEF").value).toBe("0xdeadbeef")
138
170
  })
139
171
 
@@ -1,7 +1,14 @@
1
1
  import { bytesToString, type Hex, hexToBytes, pad, size, stringToBytes, toHex } from "viem"
2
2
  import { unitsToDecimal } from "./decimal"
3
3
  import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
4
- import { type AnyArkivValue, makeValue, type StrValue, TYPE_IDS, type TypeTag } from "./types"
4
+ import {
5
+ type ArkivValue,
6
+ INDEXED_FLAG,
7
+ makeValue,
8
+ type StrValue,
9
+ TYPE_IDS,
10
+ type TypeTag,
11
+ } from "./types"
5
12
  import { addr, bool, bytes32, dec, decUnits, i32, key, u64, u256 } from "./values"
6
13
 
7
14
  function strFromChain(value: string): StrValue {
@@ -12,13 +19,14 @@ function strFromChain(value: string): StrValue {
12
19
  * The `typeId` marking a tombstone — an attribute being unset. It carries a zero-length value and
13
20
  * is legal only in a patch's mutation list, never in a create.
14
21
  */
15
- export const TOMBSTONE_TYPE_ID = 0
22
+ const TOMBSTONE_TYPE_ID = 0
16
23
 
17
24
  /**
18
25
  * An attribute in its ABI form: `(Ident32 name, uint8 typeId, bytes value)`.
19
26
  *
20
- * The `value` encoding is selected by `typeId`: an exact 32-byte word for the word types, the raw
21
- * bytes for `str` and `bytes`, and zero-length for a tombstone.
27
+ * The low seven bits of `typeId` select the type, and bit 7 ({@link INDEXED_FLAG}) says whether
28
+ * the node indexes the attribute. The `value` encoding is selected by the type: an exact 32-byte
29
+ * word for the word types, the raw bytes for `str` and `bytes`, and zero-length for a tombstone.
22
30
  */
23
31
  export type AbiAttribute = {
24
32
  name: Hex
@@ -35,7 +43,7 @@ export type AbiAttribute = {
35
43
  * bytes, in the encoding Solidity would use — right-aligned, and sign-extended for `i32`.
36
44
  * - `str` becomes its raw UTF-8 bytes, and `bytes` its raw bytes: variable length, no padding.
37
45
  */
38
- export function encodeValueBytes(value: AnyArkivValue): Hex {
46
+ export function encodeValueBytes(value: ArkivValue): Hex {
39
47
  switch (value.type) {
40
48
  case "bool":
41
49
  return pad(value.value ? "0x01" : "0x00", { size: 32 })
@@ -66,7 +74,7 @@ export function encodeValueBytes(value: AnyArkivValue): Hex {
66
74
  * 32 bytes, an `i32` that is not sign-extended, a `bool` that is neither 0 nor 1 — rather than
67
75
  * silently truncating it.
68
76
  */
69
- export function decodeValueBytes(tag: TypeTag, value: Hex): AnyArkivValue {
77
+ export function decodeValueBytes(tag: TypeTag, value: Hex): ArkivValue {
70
78
  if (tag === "str") return strFromChain(bytesToString(hexToBytes(value)))
71
79
  if (tag === "bytes") return makeValue("bytes", value.toLowerCase() as Hex)
72
80
 
@@ -118,18 +126,39 @@ export function decodeValueBytes(tag: TypeTag, value: Hex): AnyArkivValue {
118
126
  /**
119
127
  * Encodes a named, typed attribute into its ABI form.
120
128
  *
121
- * @param name - The attribute name. Assumed already validated, or a system (`$`) name the SDK sets
122
- * itself.
129
+ * @param name - The attribute name. Assumed already validated.
130
+ * @param indexed - Whether the node should index it. Never true for `bytes`, which the engine
131
+ * rejects with the flag set.
132
+ * @throws {InvalidValueError} If asked to index a `bytes` value.
123
133
  */
124
- export function encodeAbiAttribute(name: string, value: AnyArkivValue): AbiAttribute {
134
+ export function encodeAbiAttribute(
135
+ name: string,
136
+ value: ArkivValue,
137
+ indexed: boolean,
138
+ ): AbiAttribute {
139
+ if (indexed && value.type === "bytes") {
140
+ throw new InvalidValueError("bytes", `<${name}>`, "bytes are stored, never indexed")
141
+ }
125
142
  return {
126
143
  // Ident32 is left-aligned and null-padded, which is what `toHex` does for a string.
127
144
  name: toHex(name, { size: 32 }),
128
- typeId: TYPE_IDS[value.type],
145
+ typeId: TYPE_IDS[value.type] | (indexed ? INDEXED_FLAG : 0),
129
146
  value: encodeValueBytes(value),
130
147
  }
131
148
  }
132
149
 
150
+ /**
151
+ * Splits a wire `typeId` into its type and indexed flag.
152
+ *
153
+ * @throws {UnknownAttributeTypeError} If the low seven bits name no known type.
154
+ */
155
+ export function decodeTypeId(typeId: number): { tag: TypeTag; indexed: boolean } {
156
+ const base = typeId & ~INDEXED_FLAG
157
+ const tag = (Object.keys(TYPE_IDS) as TypeTag[]).find((candidate) => TYPE_IDS[candidate] === base)
158
+ if (tag === undefined) throw new UnknownAttributeTypeError(typeId)
159
+ return { tag, indexed: (typeId & INDEXED_FLAG) !== 0 }
160
+ }
161
+
133
162
  /** Encodes a tombstone — the mutation that unsets `name`. Legal only in a patch. */
134
163
  export function encodeTombstone(name: string): AbiAttribute {
135
164
  return { name: toHex(name, { size: 32 }), typeId: TOMBSTONE_TYPE_ID, value: "0x" }
@@ -149,7 +178,7 @@ export function encodeTombstone(name: string): AbiAttribute {
149
178
  * @throws {UnknownAttributeTypeError} If the tag names no known type.
150
179
  * @throws {InvalidValueError} If the value does not parse as that type.
151
180
  */
152
- export function decodeRpcValue(type: string, value: unknown): AnyArkivValue {
181
+ export function decodeRpcValue(type: string, value: unknown): ArkivValue {
153
182
  const tag = asTypeTag(type)
154
183
  switch (tag) {
155
184
  case "bool":