@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,85 +1,246 @@
1
- import { validateAttributeName } from "../attr"
2
- import type { Entity } from "../types/entity"
1
+ import {
2
+ type AnyColumn,
3
+ type ArkivValue,
4
+ type AttributeSchema,
5
+ type Attributes,
6
+ type ColumnsOf,
7
+ describeColumns,
8
+ lookupColumn,
9
+ SCHEMA_ATTRIBUTE,
10
+ type Schema,
11
+ SchemaMismatchError,
12
+ type StrValue,
13
+ type TypeTag,
14
+ type ValueOf,
15
+ validateAttributeName,
16
+ } from "../schema"
17
+ import type { Entity, EntityFields } from "../types/entity"
3
18
  import type { RpcSelect } from "../types/rpcSchema"
4
19
 
5
20
  /**
6
- * The entity fields a query can select. Each maps one-to-one onto a field of {@link Entity}.
7
- *
8
- * `attributes` takes a third form beyond `true`/`false`: a map of names, to fetch only those.
9
- */
10
- export type SelectionFields = {
11
- key?: boolean
12
- owner?: boolean
13
- creator?: boolean
14
- createdAt?: boolean
15
- updatedAt?: boolean
16
- expiresAt?: boolean
17
- creationFlags?: boolean
18
- contentType?: boolean
19
- payload?: boolean
20
- /** Attribute names and types, without their values. */
21
- attributeSchema?: boolean
22
- /** `true` for every attribute, or a map of names for a subset. */
23
- attributes?: boolean | Readonly<Record<string, boolean>>
21
+ * The entity fields a row can carry. Each is `$`-prefixed, like the system attributes `where`
22
+ * filters on: an attribute name cannot start with `$`, so no attribute can collide with one.
23
+ */
24
+ export const SYSTEM_FIELDS = [
25
+ "$key",
26
+ "$owner",
27
+ "$creator",
28
+ "$createdAt",
29
+ "$updatedAt",
30
+ "$expiresAt",
31
+ "$creationFlags",
32
+ ] as const
33
+
34
+ /** The name of an entity field a row carries alongside its attributes. */
35
+ export type SystemField = (typeof SYSTEM_FIELDS)[number]
36
+
37
+ /** The {@link Entity} property a system field reads. */
38
+ type EntityField<F extends SystemField> = F extends `$${infer N extends keyof EntityFields}`
39
+ ? N
40
+ : never
41
+
42
+ /**
43
+ * The keys a selection gives a fixed meaning: the entity fields, plus `$attributes` and
44
+ * `$attributeSchema`, which read an entity's attributes as a whole. Any key without a `$` names an
45
+ * attribute.
46
+ */
47
+ export type SelectionFields = { [K in SystemField]?: boolean } & {
48
+ /**
49
+ * Attribute names, types and indexing, without their values — a cheap way to find out what
50
+ * entities hold before fetching values that may be large. See {@link AttributeSchema}.
51
+ */
52
+ $attributeSchema?: boolean
53
+ /** Every attribute, as a map keyed by name. */
54
+ $attributes?: boolean
24
55
  }
25
56
 
26
- /** The selection keys, in the order they are written into the RPC `select` object. */
57
+ /** The selection keys with a fixed meaning, in the order they are written into the RPC `select`. */
27
58
  const SELECTION_KEYS = [
28
- "key",
29
- "owner",
30
- "creator",
31
- "createdAt",
32
- "updatedAt",
33
- "expiresAt",
34
- "creationFlags",
35
- "contentType",
36
- "payload",
37
- "attributeSchema",
38
- "attributes",
59
+ ...SYSTEM_FIELDS,
60
+ "$attributeSchema",
61
+ "$attributes",
39
62
  ] as const satisfies readonly (keyof SelectionFields)[]
40
63
 
64
+ /** A selection key with a fixed meaning. */
65
+ type SelectionKey = (typeof SELECTION_KEYS)[number]
66
+
41
67
  /**
42
- * Requires at least one property of `T` to be present.
43
- * `{}` is not assignable, which is how `select({})` is rejected at compile time.
68
+ * A selection: entity fields, `$attributes` / `$attributeSchema`, and attributes by name, each set
69
+ * to `true` to ask for it. The same selection means the same thing with or without a schema —
70
+ * naming one with `.from(...)` only types and checks the selected attributes, and the filter. A
71
+ * named schema also restricts the query to entities tagged with its name, and leaves the tag out of
72
+ * every row.
44
73
  */
45
- type AtLeastOne<T, K extends keyof T = keyof T> = {
46
- [P in K]: Required<Pick<T, P>> & Partial<Omit<T, P>>
47
- }[K]
74
+ export type Selection = Readonly<Record<string, boolean | undefined>>
75
+
76
+ /** Argument accepted by `select()` — a {@link Selection}, or `"*"` for everything. */
77
+ export type SelectArg = Selection | "*"
78
+
79
+ /** Whether a selection value asks for the field: `true` does, `false` and `undefined` do not. */
80
+ export type Selected<V> = V extends false | undefined ? false : true
48
81
 
49
82
  /**
50
- * A non-empty entity selection — the object form accepted by `select()`. At least one field must be
51
- * selected.
83
+ * What a column reads back as: a value of its type, or `undefined` too if the column is optional.
52
84
  */
53
- export type EntitySelection = AtLeastOne<SelectionFields>
54
-
55
- /** Argument accepted by `select()` — a non-empty {@link EntitySelection}, or `"*"` for everything. */
56
- export type SelectArg = EntitySelection | "*"
85
+ export type ColumnValue<C> = C extends {
86
+ readonly type: infer T extends TypeTag
87
+ readonly isOptional: infer O
88
+ }
89
+ ? ValueOf<T> | (true extends O ? undefined : never)
90
+ : never
57
91
 
58
- /** Whether a selection value asks for the field: `true`, or a non-empty map of attribute names. */
59
- type Selected<V> = V extends false | undefined ? false : true
92
+ /**
93
+ * What a selected key reads back as: an entity field as the entity has it, the attributes as a
94
+ * whole, or one attribute — typed by its column through a schema, and any value or none without.
95
+ */
96
+ type FieldValue<K, TSchema extends Schema | undefined> = K extends SystemField
97
+ ? NonNullable<Entity[EntityField<K>]>
98
+ : K extends "$attributes"
99
+ ? Attributes
100
+ : K extends "$attributeSchema"
101
+ ? AttributeSchema
102
+ : K extends `$${string}`
103
+ ? never
104
+ : TSchema extends Schema
105
+ ? K extends keyof ColumnsOf<TSchema>
106
+ ? ColumnValue<ColumnsOf<TSchema>[K]>
107
+ : never
108
+ : ArkivValue | undefined
60
109
 
61
110
  /**
62
- * The result of a selection `S`: an {@link Entity} narrowed to exactly the selected fields (each
63
- * non-`undefined`). The shape is flat — `select({ owner: true })` yields `{ owner: Hex }`, read as
64
- * `entity.owner`. Accessing an unselected field is a compile error.
111
+ * One result of selection `S`, through schema `TSchema` or without one: the selected entity fields
112
+ * and attributes side by side, flat. Through a schema each attribute is typed as its column
113
+ * declares; without one it is whatever value the entity holds, or `undefined`. Accessing an
114
+ * unselected field is a compile error.
65
115
  *
66
- * `toText()` / `toJson()` operate on the payload, so they appear only when `payload` was selected —
67
- * calling them otherwise would always throw.
116
+ * @example
117
+ * // select({ $key: true, title: true }) → { $key: Hex; title: ArkivValue | undefined }
118
+ * // select({ $key: true, title: true }).from(Posts) → { $key: Hex; title: StrValue }
68
119
  */
69
- export type ProjectedEntity<S> = {
70
- readonly [K in keyof S as Selected<S[K]> extends true ? K : never]: K extends keyof Entity
71
- ? NonNullable<Entity[K]>
72
- : never
73
- } & (S extends { payload: true } ? Pick<Entity, "toText" | "toJson"> : unknown)
120
+ export type Row<S, TSchema extends Schema | undefined = undefined> = {
121
+ readonly [K in keyof S as Selected<S[K]> extends true ? K : never]: FieldValue<K, TSchema>
122
+ }
74
123
 
75
- /** A selection of every field, used to type the result of `select()` / `select("*")`. */
76
- type AllSelected = { [K in (typeof SELECTION_KEYS)[number]]: true }
124
+ /**
125
+ * What `select()` / `select("*")` selects without a schema: every entity field and every
126
+ * attribute. Not the `$attributeSchema`: its point is learning an entity's shape without fetching
127
+ * its values, so returning it next to every value would only add to the response. It is only
128
+ * returned when asked for by name.
129
+ */
130
+ type AllSelected = { [K in SystemField | "$attributes"]: true }
77
131
 
78
- /** The projected entity type when everything is selected — every field, plus the methods. */
79
- export type FullEntity = ProjectedEntity<AllSelected>
132
+ /**
133
+ * The result when everything is selected without a schema — every entity field, and every
134
+ * attribute as the `$attributes` map. Also what `getEntity(key)` returns.
135
+ */
136
+ export type FullEntity = Row<AllSelected>
137
+
138
+ /** The row type when everything is selected through a schema — every entity field and column. */
139
+ export type FullRow<TSchema extends Schema> = Row<
140
+ { [K in SystemField | keyof ColumnsOf<TSchema>]: true },
141
+ TSchema
142
+ >
143
+
144
+ declare const dynamic: unique symbol
80
145
 
81
146
  /**
82
- * Converts a {@link SelectArg} into the RPC `select` object.
147
+ * The selection type of a builder whose selection is only known at runtime — `select(sel)` with
148
+ * `sel` typed {@link SelectArg} rather than written inline. Nothing about which fields will be
149
+ * present can be known statically, so results are typed with every field optional.
150
+ */
151
+ export type DynamicSelection = { readonly [dynamic]: true }
152
+
153
+ /**
154
+ * A result whose selection is only known at runtime: any entity field and any attribute may be
155
+ * absent, because nothing says statically which were selected.
156
+ */
157
+ export type PartialRow<TSchema extends Schema | undefined = undefined> = {
158
+ readonly [K in SystemField]?: NonNullable<Entity[EntityField<K>]>
159
+ } & {
160
+ readonly $attributes?: Attributes
161
+ readonly $attributeSchema?: AttributeSchema
162
+ } & (TSchema extends Schema
163
+ ? { readonly [K in keyof ColumnsOf<TSchema>]?: ValueOf<ColumnsOf<TSchema>[K]["type"]> }
164
+ : { readonly [name: string]: ArkivValue | undefined })
165
+
166
+ /**
167
+ * Whether which fields `S` selects is only known at runtime: a selection typed {@link SelectArg} or
168
+ * {@link Selection}, or one with a field typed `boolean` rather than `true` or `false`.
169
+ */
170
+ type IsDynamic<S> = string extends keyof Exclude<S, "*">
171
+ ? true
172
+ : S extends "*"
173
+ ? false
174
+ : true extends {
175
+ [K in keyof S]: [S[K]] extends [boolean] ? (boolean extends S[K] ? true : false) : false
176
+ }[keyof S]
177
+ ? true
178
+ : false
179
+
180
+ /**
181
+ * The selection type a query carries for the selection `select()` or `query()` was given: `"*"`
182
+ * for everything, {@link DynamicSelection} when the fields are only known at runtime, and the
183
+ * selection itself otherwise.
184
+ */
185
+ export type SelectionOf<S> = [S] extends ["*"]
186
+ ? "*"
187
+ : IsDynamic<S> extends true
188
+ ? DynamicSelection
189
+ : S
190
+
191
+ /**
192
+ * Rejects a selection that asks for nothing at compile time — `{}`, or every field `false`:
193
+ * `unknown` when `S` asks for something, or when that is only known at runtime, and an object no
194
+ * selection satisfies otherwise.
195
+ */
196
+ export type NonEmptySelection<S> = SelectionOf<S> extends "*" | DynamicSelection
197
+ ? unknown
198
+ : [keyof { [K in keyof S as Selected<S[K]> extends true ? K : never]: true }] extends [never]
199
+ ? { "select() needs at least one field set to true": true }
200
+ : unknown
201
+
202
+ /**
203
+ * Rejects a `$` key that is not an entity field, `$attributes` or `$attributeSchema` at compile
204
+ * time. An attribute name cannot start with `$`, so such a key can only be a typo: `unknown` when
205
+ * there is none, and an object naming the offenders, which no selection satisfies, otherwise.
206
+ */
207
+ export type KnownSystemFields<S> = SelectionOf<S> extends "*" | DynamicSelection
208
+ ? unknown
209
+ : [Extract<Exclude<keyof S, SelectionKey>, `$${string}`>] extends [never]
210
+ ? unknown
211
+ : {
212
+ "selected names that are not entity fields": Extract<
213
+ Exclude<keyof S, SelectionKey>,
214
+ `$${string}`
215
+ >
216
+ }
217
+
218
+ /** What a query returns per result — a flat row, typed by the schema when there is one. */
219
+ export type ResultOf<
220
+ TSelection,
221
+ TSchema extends Schema | undefined,
222
+ > = TSelection extends DynamicSelection
223
+ ? PartialRow<TSchema>
224
+ : TSelection extends "*"
225
+ ? TSchema extends Schema
226
+ ? FullRow<TSchema>
227
+ : FullEntity
228
+ : Row<TSelection, TSchema>
229
+
230
+ /**
231
+ * The keys of a selection that mean nothing through `TSchema` — neither an entity field,
232
+ * `$attributes` / `$attributeSchema`, nor a column. None for a star or a selection only known at
233
+ * runtime, which `from()` checks when it runs instead.
234
+ */
235
+ export type UnknownSelection<TSelection, TSchema extends Schema> = TSelection extends
236
+ | "*"
237
+ | DynamicSelection
238
+ ? never
239
+ : Exclude<keyof TSelection, SelectionKey | keyof ColumnsOf<TSchema>>
240
+
241
+ /**
242
+ * Converts a selection into the RPC `select` object — the same request {@link projectSelection}
243
+ * makes without a schema.
83
244
  *
84
245
  * Every field is opt-in — anything not explicitly selected is sent as `false`, including the key.
85
246
  * Sending the full object rather than omitting the `false`s keeps what the SDK asked for legible in
@@ -87,42 +248,222 @@ export type FullEntity = ProjectedEntity<AllSelected>
87
248
  *
88
249
  * `undefined` or `"*"` selects everything.
89
250
  *
90
- * @throws If the selection asks for nothing.
251
+ * @throws If the selection asks for nothing, or names an attribute outside the name grammar.
91
252
  *
92
253
  * @example
93
- * toRpcSelect() // everything
94
- * toRpcSelect({ key: true }) // only the key
95
- * toRpcSelect({ attributes: { projectId: true } }) // one named attribute
254
+ * toRpcSelect() // everything
255
+ * toRpcSelect({ $key: true }) // only the key
256
+ * toRpcSelect({ $key: true, projectId: true }) // the key and one attribute
96
257
  */
97
258
  export function toRpcSelect(selection?: SelectArg): RpcSelect {
259
+ return projectSelection(selection).select
260
+ }
261
+
262
+ /**
263
+ * A frozen copy of a selection, so that editing the object passed to `select()` afterwards cannot
264
+ * change what the builder sends.
265
+ */
266
+ export function snapshotSelection<S extends SelectArg | undefined>(selection: S): S {
267
+ if (typeof selection !== "object" || selection === null) return selection
268
+ return Object.freeze({ ...selection }) as S
269
+ }
270
+
271
+ /**
272
+ * A selection resolved, through a schema or without one: what to ask the node for, and how to lay
273
+ * the answer out.
274
+ */
275
+ export type Projection = {
276
+ readonly select: RpcSelect
277
+ /** The entity fields each row carries. */
278
+ readonly fields: readonly SystemField[]
279
+ /**
280
+ * The attributes each row carries as fields of their own, by name — each with its column
281
+ * through a schema, which the value is checked against, and `undefined` without one.
282
+ */
283
+ readonly columns: readonly (readonly [name: string, column: AnyColumn | undefined])[]
284
+ /** Whether each row carries the `$attributes` map. */
285
+ readonly attributes: boolean
286
+ /** Whether each row carries the `$attributeSchema`. */
287
+ readonly attributeSchema: boolean
288
+ /** The tag each entity must carry, for a named schema. Checked, never part of the row. */
289
+ readonly tag: StrValue | undefined
290
+ }
291
+
292
+ /**
293
+ * Resolves a selection, through a schema or without one. A key that does not start with `$` names
294
+ * an attribute — through a schema, it must be a column.
295
+ *
296
+ * `undefined` or `"*"` selects every entity field and, through a schema, every column; without one,
297
+ * every attribute as the `$attributes` map. The `$attributeSchema` is only selected by name.
298
+ *
299
+ * Attributes are fetched by name where possible, so the ones a row does not carry stay on the node.
300
+ * A named schema's tag is fetched too, so each entity can be checked against it.
301
+ *
302
+ * @throws If the selection asks for nothing, names a `$` key that is not an entity field, names an
303
+ * attribute outside the name grammar, or — through a schema — names an attribute that is not a
304
+ * column.
305
+ */
306
+ export function projectSelection(selection?: SelectArg, schema?: Schema): Projection {
307
+ const fields: SystemField[] = []
308
+ const columns: [string, AnyColumn | undefined][] = []
309
+ let attributes = false
310
+ let attributeSchema = false
311
+
98
312
  if (selection === undefined || selection === "*") {
99
- return Object.fromEntries(SELECTION_KEYS.map((field) => [field, true])) as RpcSelect
313
+ fields.push(...SYSTEM_FIELDS)
314
+ if (schema === undefined) {
315
+ attributes = true
316
+ } else {
317
+ columns.push(...Object.entries(schema.columns))
318
+ }
319
+ } else {
320
+ if (typeof selection !== "object" || selection === null || Array.isArray(selection)) {
321
+ throw new TypeError('select() takes "*" or an object of fields set to true')
322
+ }
323
+ for (const [name, wanted] of Object.entries(selection)) {
324
+ if (typeof wanted !== "boolean" && wanted !== undefined) {
325
+ throw new Error(
326
+ name === "$attributes"
327
+ ? 'select() takes true or false for "$attributes", which reads every attribute. To ' +
328
+ "read some of them, select each by name: select({ title: true, body: true })."
329
+ : `select() takes true or false for "${name}", got ${typeof wanted}.`,
330
+ )
331
+ }
332
+ const fixed = (SELECTION_KEYS as readonly string[]).includes(name)
333
+ if (!fixed && name.startsWith("$")) {
334
+ throw new Error(
335
+ `select() names "${name}", which is not an entity field. The entity fields are ` +
336
+ `${SYSTEM_FIELDS.join(", ")}, and $attributes and $attributeSchema read the ` +
337
+ "attributes as a whole.",
338
+ )
339
+ }
340
+ const column = fixed || schema === undefined ? undefined : lookupColumn(schema, name)
341
+ if (!fixed) {
342
+ if (schema !== undefined && column === undefined) {
343
+ throw new Error(
344
+ `select() names "${name}", which is not a column of the schema. Its columns are ` +
345
+ `${describeColumns(schema)}.`,
346
+ )
347
+ }
348
+ validateAttributeName(name)
349
+ }
350
+ if (!wanted) continue
351
+ if (name === "$attributes") attributes = true
352
+ else if (name === "$attributeSchema") attributeSchema = true
353
+ else if (fixed) fields.push(name as SystemField)
354
+ else columns.push([name, column])
355
+ }
356
+ // Rejects both `{}` and a selection whose fields are all explicitly `false` — the type system
357
+ // permits `select({ $key: false })`, and the node would answer it with a page of empty objects.
358
+ if (fields.length === 0 && columns.length === 0 && !attributes && !attributeSchema) {
359
+ throw nothingSelected()
360
+ }
100
361
  }
101
362
 
102
363
  const select: RpcSelect = {}
103
- let any = false
104
- for (const field of SELECTION_KEYS) {
105
- const value = selection[field]
106
- if (field === "attributes" && typeof value === "object" && value !== null) {
107
- // A named subset. An empty map asks for no attributes at all, which is what `false` means.
108
- const names = Object.entries(value).filter(([, wanted]) => wanted)
109
- // Checked rather than forwarded: a name outside the grammar cannot exist on any entity, so
110
- // asking for one is a typo that would otherwise come back as a silently absent attribute.
111
- for (const [name] of names) validateAttributeName(name)
112
- select.attributes = names.length > 0 ? Object.fromEntries(names) : false
113
- any ||= names.length > 0
114
- continue
364
+ for (const field of SYSTEM_FIELDS) select[entityField(field)] = fields.includes(field)
365
+ select.attributeSchema = attributeSchema
366
+ if (attributes) {
367
+ select.attributes = true
368
+ } else {
369
+ const names = new Set(columns.map(([name]) => name))
370
+ if (schema?.tag !== undefined) names.add(SCHEMA_ATTRIBUTE)
371
+ select.attributes =
372
+ names.size > 0 ? Object.fromEntries([...names].map((n) => [n, true])) : false
373
+ }
374
+ return { select, fields, columns, attributes, attributeSchema, tag: schema?.tag }
375
+ }
376
+
377
+ /**
378
+ * Lays an entity out as a row of `projection` — checking it against the schema, when there is one.
379
+ *
380
+ * @throws {SchemaMismatchError} If a named schema's entity is not tagged with its name, or the
381
+ * entity lacks a selected required column or holds one with a type other than the schema's — it
382
+ * was not written through this schema, or was patched since.
383
+ */
384
+ export function toRow(entity: Entity, projection: Projection): Record<string, unknown> {
385
+ const subject = entity.key === undefined ? "the entity" : `entity ${entity.key}`
386
+
387
+ const { tag } = projection
388
+ if (tag !== undefined) {
389
+ const found = attributeOf(entity, SCHEMA_ATTRIBUTE)
390
+ if (found?.type !== "str" || found.value !== tag.value) {
391
+ throw new SchemaMismatchError(
392
+ SCHEMA_ATTRIBUTE,
393
+ `${subject} is ${describeTag(found)}, not ${JSON.stringify(tag.value)}, so it was not ` +
394
+ "written through this schema",
395
+ )
115
396
  }
116
- select[field] = value === true
117
- any ||= value === true
118
397
  }
119
398
 
120
- // Rejects both `{}` and a selection whose fields are all explicitly `false` — the type system
121
- // permits `select({ key: false })`, and the node would answer it with a page of empty objects.
122
- if (!any) {
123
- throw new Error(
124
- 'select() requires at least one field to be selected. Pass select() or select("*") to select everything.',
125
- )
399
+ const row: Record<string, unknown> = {}
400
+ for (const field of projection.fields) row[field] = entity[entityField(field)]
401
+
402
+ for (const [name, column] of projection.columns) {
403
+ const value = attributeOf(entity, name)
404
+ if (column !== undefined) {
405
+ if (value === undefined) {
406
+ if (!column.isOptional) {
407
+ throw new SchemaMismatchError(
408
+ name,
409
+ `${subject} has no value for this required ${column.type} column, so it does not ` +
410
+ "match the schema. Declare the column .optional() if entities may leave it unset",
411
+ )
412
+ }
413
+ } else if (value.type !== column.type) {
414
+ throw new SchemaMismatchError(
415
+ name,
416
+ `${subject} holds a ${value.type}, not the ${column.type} the schema declares`,
417
+ )
418
+ }
419
+ }
420
+ row[name] = value
126
421
  }
127
- return select
422
+
423
+ if (projection.attributes) {
424
+ row.$attributes = withoutTag(entity.attributes ?? {}, tag)
425
+ }
426
+ if (projection.attributeSchema) {
427
+ row.$attributeSchema = withoutTag(entity.attributeSchema ?? {}, tag)
428
+ }
429
+ return row
430
+ }
431
+
432
+ /** The {@link Entity} property, and RPC `select` key, a system field reads: its name without `$`. */
433
+ function entityField<F extends SystemField>(field: F): EntityField<F> {
434
+ return field.slice(1) as EntityField<F>
435
+ }
436
+
437
+ /**
438
+ * A copy of `record` without a named schema's tag: the tag is how a query finds the schema's
439
+ * entities, not one of their attributes, so a row never carries it — and a map read back from a
440
+ * row drops into a write without sending the reserved name.
441
+ */
442
+ function withoutTag<T>(record: Readonly<Record<string, T>>, tag: StrValue | undefined) {
443
+ if (tag === undefined || !Object.hasOwn(record, SCHEMA_ATTRIBUTE)) return record
444
+ const { [SCHEMA_ATTRIBUTE]: _tag, ...rest } = record
445
+ return rest
446
+ }
447
+
448
+ /**
449
+ * The entity's attribute `name`, or `undefined` if it has none. An own-property read: a column may
450
+ * be named `constructor` or `toString`, and an entity that leaves it unset must not find the
451
+ * inherited one instead.
452
+ */
453
+ function attributeOf(entity: Entity, name: string): ArkivValue | undefined {
454
+ const { attributes } = entity
455
+ return attributes !== undefined && Object.hasOwn(attributes, name) ? attributes[name] : undefined
456
+ }
457
+
458
+ /** How an entity's schema tag reads in a mismatch — whatever type another writer gave it. */
459
+ function describeTag(found: ArkivValue | undefined): string {
460
+ if (found === undefined) return "not tagged with a schema"
461
+ if (found.type === "str") return `tagged ${JSON.stringify(found.value)}`
462
+ return `tagged with a value of type ${found.type}`
463
+ }
464
+
465
+ function nothingSelected(): Error {
466
+ return new Error(
467
+ 'select() requires at least one field to be selected. Pass select() or select("*") to select everything.',
468
+ )
128
469
  }