@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.
- package/README.md +180 -61
- package/dist/chains/index.d.ts +17 -17
- package/dist/changeOwnership-Cy9gfyjN.d.cts +1160 -0
- package/dist/changeOwnership-Cy9gfyjN.d.cts.map +1 -0
- package/dist/changeOwnership-gXf-lBqI.d.ts +1160 -0
- package/dist/changeOwnership-gXf-lBqI.d.ts.map +1 -0
- package/dist/entity/index.cjs +4 -10
- package/dist/entity/index.d.cts +3 -3
- package/dist/entity/index.d.ts +3 -3
- package/dist/entity/index.js +3 -4
- package/dist/entity-BM1unrQf.cjs +41 -0
- package/dist/{entity-DJ0Gml9c.cjs → entity-BgaFJSaV.cjs} +4 -4
- package/dist/entity-CFAI1KyG.js +36 -0
- package/dist/entity-CFAI1KyG.js.map +1 -0
- package/dist/{entity-CniBgBSd.js → entity-CGqx_AU0.js} +4 -4
- package/dist/entity-CGqx_AU0.js.map +1 -0
- package/dist/{payload-AfUfSctb.d.ts → expirationTime-C7PZK49K.d.ts} +8 -37
- package/dist/expirationTime-C7PZK49K.d.ts.map +1 -0
- package/dist/expirationTime-CkIGNAg8.cjs +140 -0
- package/dist/{payload-D167BlJR.d.cts → expirationTime-DOB8V4B8.d.cts} +8 -37
- package/dist/expirationTime-DOB8V4B8.d.cts.map +1 -0
- package/dist/{expiry-Cgdb_dbq.js → expirationTime-UKPuA-LM.js} +62 -27
- package/dist/expirationTime-UKPuA-LM.js.map +1 -0
- package/dist/expiry-B48I-yTA.d.cts +40 -0
- package/dist/expiry-B48I-yTA.d.cts.map +1 -0
- package/dist/expiry-S2PUL8Hg.d.ts +40 -0
- package/dist/expiry-S2PUL8Hg.d.ts.map +1 -0
- package/dist/index-C-mMF5WZ.d.cts +735 -0
- package/dist/index-C-mMF5WZ.d.cts.map +1 -0
- package/dist/{index-BkqSnGJH.d.ts → index-C4zZqJ9S.d.ts} +5 -27
- package/dist/index-C4zZqJ9S.d.ts.map +1 -0
- package/dist/{index-CLhsFyVH.d.cts → index-CcVWYB6v.d.cts} +5 -27
- package/dist/index-CcVWYB6v.d.cts.map +1 -0
- package/dist/index-DCx2XC6S.d.ts +489 -0
- package/dist/index-DCx2XC6S.d.ts.map +1 -0
- package/dist/index-DLb7BfIw.d.ts +735 -0
- package/dist/index-DLb7BfIw.d.ts.map +1 -0
- package/dist/index-DzriOUm8.d.cts +489 -0
- package/dist/index-DzriOUm8.d.cts.map +1 -0
- package/dist/index.cjs +162 -146
- package/dist/index.d.cts +180 -133
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +180 -133
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -83
- package/dist/index.js.map +1 -1
- package/dist/params-BuIrJA9a.cjs +45 -0
- package/dist/params-DDJOnsNi.js +22 -0
- package/dist/params-DDJOnsNi.js.map +1 -0
- package/dist/query/index.cjs +9 -4
- package/dist/query/index.d.cts +6 -6
- package/dist/query/index.d.ts +6 -6
- package/dist/query/index.js +4 -5
- package/dist/query-D0KtuFh2.cjs +1625 -0
- package/dist/query-DpRDozmj.js +1417 -0
- package/dist/query-DpRDozmj.js.map +1 -0
- package/dist/schema/index.cjs +46 -0
- package/dist/schema/index.d.cts +2 -0
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.js +3 -0
- package/dist/{attr-BXTkpnoc.cjs → schema-B9AyXcgU.cjs} +513 -246
- package/dist/{attr-tmyREHtw.js → schema-DY4sqehl.js} +427 -244
- package/dist/schema-DY4sqehl.js.map +1 -0
- package/dist/types/index.cjs +1 -1
- package/dist/types/index.d.cts +5 -5
- package/dist/types/index.d.ts +5 -5
- package/dist/types/index.js +1 -1
- package/dist/utils/index.cjs +3 -5
- package/dist/utils/index.d.cts +3 -3
- package/dist/utils/index.d.ts +3 -3
- package/dist/utils/index.js +3 -3
- package/package.json +9 -12
- package/src/actions/public/getEntity.test.ts +69 -0
- package/src/actions/public/getEntity.ts +34 -20
- package/src/actions/public/predictEntityKeys.ts +8 -7
- package/src/actions/public/query.test.ts +58 -0
- package/src/actions/public/query.ts +41 -13
- package/src/actions/wallet/createEntity.ts +34 -63
- package/src/actions/wallet/executeBatch.test.ts +38 -0
- package/src/actions/wallet/executeBatch.ts +26 -9
- package/src/actions/wallet/extendEntity.ts +9 -8
- package/src/actions/wallet/patchEntity.ts +43 -52
- package/src/clients/createPublicClient.test.ts +10 -2
- package/src/clients/createWalletClient.ts +4 -1
- package/src/clients/decorators/arkivPublic.ts +123 -80
- package/src/clients/decorators/arkivWallet.ts +113 -65
- package/src/entity/create.test.ts +25 -21
- package/src/entity/expiry.ts +8 -6
- package/src/entity/flags.ts +1 -1
- package/src/entity/index.ts +2 -4
- package/src/entity/key.ts +3 -3
- package/src/entity/operations.ts +1 -1
- package/src/errors.ts +2 -11
- package/src/index.ts +18 -6
- package/src/query/builderBase.ts +166 -0
- package/src/query/countBuilder.test.ts +91 -0
- package/src/query/countBuilder.ts +67 -0
- package/src/query/engine.ts +9 -3
- package/src/query/errors.ts +24 -14
- package/src/query/expression.test.ts +129 -21
- package/src/query/expression.ts +323 -94
- package/src/query/index.ts +26 -4
- package/src/query/injection.test.ts +5 -11
- package/src/query/queryBuilder.test.ts +467 -58
- package/src/query/queryBuilder.ts +191 -98
- package/src/query/queryResult.ts +35 -9
- package/src/query/selection.test.ts +278 -24
- package/src/query/selection.ts +427 -86
- package/src/schema/attributes.test.ts +266 -0
- package/src/schema/attributes.ts +212 -0
- package/src/{attr → schema}/codec.test.ts +37 -5
- package/src/{attr → schema}/codec.ts +40 -11
- package/src/schema/column.ts +193 -0
- package/src/{attr → schema}/errors.ts +67 -10
- package/src/schema/index.ts +76 -0
- package/src/{attr → schema}/names.ts +4 -2
- package/src/schema/schema.test.ts +171 -0
- package/src/schema/schema.ts +250 -0
- package/src/{attr → schema}/types.ts +36 -30
- package/src/{attr → schema}/values.test.ts +62 -3
- package/src/{attr → schema}/values.ts +246 -65
- package/src/types/entity.test.ts +1 -44
- package/src/types/entity.ts +9 -55
- package/src/types/events.ts +1 -1
- package/src/types/index.ts +11 -4
- package/src/types/rpcSchema.ts +22 -16
- package/src/utils/arkivTransactions.test.ts +356 -116
- package/src/utils/arkivTransactions.ts +30 -38
- package/src/utils/entities.ts +10 -9
- package/src/utils/expirationTime.ts +5 -5
- package/src/utils/index.ts +0 -1
- package/src/utils/logger.test.ts +45 -0
- package/src/utils/logger.ts +5 -39
- package/src/utils/revert.test.ts +25 -0
- package/src/utils/revert.ts +28 -3
- package/dist/attr/index.cjs +0 -34
- package/dist/attr/index.d.cts +0 -2
- package/dist/attr/index.d.ts +0 -2
- package/dist/attr/index.js +0 -3
- package/dist/attr-tmyREHtw.js.map +0 -1
- package/dist/entity-B0t6sN7F.cjs +0 -71
- package/dist/entity-BIp_df9u.js +0 -67
- package/dist/entity-BIp_df9u.js.map +0 -1
- package/dist/entity-CniBgBSd.js.map +0 -1
- package/dist/expiry-BxOlvVWC.d.ts +0 -79
- package/dist/expiry-BxOlvVWC.d.ts.map +0 -1
- package/dist/expiry-CG9GhVXv.cjs +0 -135
- package/dist/expiry-Cgdb_dbq.js.map +0 -1
- package/dist/expiry-DRV29F1r.d.cts +0 -79
- package/dist/expiry-DRV29F1r.d.cts.map +0 -1
- package/dist/flags-DCD26uhe.cjs +0 -43
- package/dist/flags-DUT2gHEU.js +0 -33
- package/dist/flags-DUT2gHEU.js.map +0 -1
- package/dist/index-BkqSnGJH.d.ts.map +0 -1
- package/dist/index-C7fbakoI.d.cts +0 -278
- package/dist/index-C7fbakoI.d.cts.map +0 -1
- package/dist/index-CIcHPx2j.d.cts +0 -424
- package/dist/index-CIcHPx2j.d.cts.map +0 -1
- package/dist/index-CLhsFyVH.d.cts.map +0 -1
- package/dist/index-Cd2aaWme.d.ts +0 -424
- package/dist/index-Cd2aaWme.d.ts.map +0 -1
- package/dist/index-Cq6fa8AI.d.ts +0 -278
- package/dist/index-Cq6fa8AI.d.ts.map +0 -1
- package/dist/index-DaNeJUw8.d.ts +0 -955
- package/dist/index-DaNeJUw8.d.ts.map +0 -1
- package/dist/index-Dv16LO2s.d.cts +0 -955
- package/dist/index-Dv16LO2s.d.cts.map +0 -1
- package/dist/payload-AfUfSctb.d.ts.map +0 -1
- package/dist/payload-D167BlJR.d.cts.map +0 -1
- package/dist/payload-DE7wMztL.js +0 -92
- package/dist/payload-DE7wMztL.js.map +0 -1
- package/dist/payload-DqFweqiO.cjs +0 -108
- package/dist/query-9oojUdM7.cjs +0 -1128
- package/dist/query-Ber-lOJQ.js +0 -968
- package/dist/query-Ber-lOJQ.js.map +0 -1
- package/src/actions/public/getEntityCount.ts +0 -10
- package/src/attr/attributes.test.ts +0 -120
- package/src/attr/attributes.ts +0 -196
- package/src/attr/index.ts +0 -50
- package/src/types/mimeTypes.ts +0 -27
- package/src/utils/compression.test.ts +0 -32
- package/src/utils/compression.ts +0 -102
- package/src/utils/payload.ts +0 -36
- /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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
21
|
-
*
|
|
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:
|
|
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):
|
|
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
|
|
122
|
-
*
|
|
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(
|
|
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):
|
|
181
|
+
export function decodeRpcValue(type: string, value: unknown): ArkivValue {
|
|
153
182
|
const tag = asTypeTag(type)
|
|
154
183
|
switch (tag) {
|
|
155
184
|
case "bool":
|