@haverstack/core 0.41.0 → 0.43.0

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 (317) hide show
  1. package/README.md +124 -64
  2. package/dist/access.d.ts +43 -47
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +66 -101
  5. package/dist/access.js.map +1 -1
  6. package/dist/{combine.d.ts → adapter/combine.d.ts} +8 -1
  7. package/dist/adapter/combine.d.ts.map +1 -0
  8. package/dist/{combine.js → adapter/combine.js} +18 -3
  9. package/dist/adapter/combine.js.map +1 -0
  10. package/dist/adapter/index.d.ts +19 -0
  11. package/dist/adapter/index.d.ts.map +1 -0
  12. package/dist/adapter/index.js +16 -0
  13. package/dist/adapter/index.js.map +1 -0
  14. package/dist/adapter/reach.d.ts +14 -0
  15. package/dist/adapter/reach.d.ts.map +1 -0
  16. package/dist/adapter/reach.js +31 -0
  17. package/dist/adapter/reach.js.map +1 -0
  18. package/dist/{sort.d.ts → adapter/sort.d.ts} +20 -20
  19. package/dist/adapter/sort.d.ts.map +1 -0
  20. package/dist/{sort.js → adapter/sort.js} +37 -27
  21. package/dist/adapter/sort.js.map +1 -0
  22. package/dist/associations/identity.d.ts +69 -0
  23. package/dist/associations/identity.d.ts.map +1 -0
  24. package/dist/associations/identity.js +174 -0
  25. package/dist/associations/identity.js.map +1 -0
  26. package/dist/associations/validation.d.ts +83 -0
  27. package/dist/associations/validation.d.ts.map +1 -0
  28. package/dist/associations/validation.js +312 -0
  29. package/dist/associations/validation.js.map +1 -0
  30. package/dist/attachment-records.d.ts +48 -0
  31. package/dist/attachment-records.d.ts.map +1 -0
  32. package/dist/attachment-records.js +52 -0
  33. package/dist/attachment-records.js.map +1 -0
  34. package/dist/collection.d.ts +178 -0
  35. package/dist/collection.d.ts.map +1 -0
  36. package/dist/collection.js +397 -0
  37. package/dist/collection.js.map +1 -0
  38. package/dist/content-match.d.ts +25 -0
  39. package/dist/content-match.d.ts.map +1 -0
  40. package/dist/content-match.js +51 -0
  41. package/dist/content-match.js.map +1 -0
  42. package/dist/content-translation.d.ts +42 -0
  43. package/dist/content-translation.d.ts.map +1 -0
  44. package/dist/content-translation.js +108 -0
  45. package/dist/content-translation.js.map +1 -0
  46. package/dist/did/did.d.ts.map +1 -0
  47. package/dist/{did.js → did/did.js} +3 -4
  48. package/dist/did/did.js.map +1 -0
  49. package/dist/{did-entry.d.ts → did/index.d.ts} +1 -1
  50. package/dist/did/index.d.ts.map +1 -0
  51. package/dist/{did-entry.js → did/index.js} +3 -3
  52. package/dist/did/index.js.map +1 -0
  53. package/dist/errors.d.ts +54 -66
  54. package/dist/errors.d.ts.map +1 -1
  55. package/dist/errors.js +58 -62
  56. package/dist/errors.js.map +1 -1
  57. package/dist/grants.d.ts +20 -44
  58. package/dist/grants.d.ts.map +1 -1
  59. package/dist/grants.js +69 -151
  60. package/dist/grants.js.map +1 -1
  61. package/dist/id.d.ts +13 -31
  62. package/dist/id.d.ts.map +1 -1
  63. package/dist/id.js +13 -31
  64. package/dist/id.js.map +1 -1
  65. package/dist/index.d.ts +13 -9
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +9 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/install.d.ts +3 -6
  70. package/dist/install.d.ts.map +1 -1
  71. package/dist/install.js +6 -10
  72. package/dist/install.js.map +1 -1
  73. package/dist/lazy.d.ts +13 -0
  74. package/dist/lazy.d.ts.map +1 -0
  75. package/dist/lazy.js +27 -0
  76. package/dist/lazy.js.map +1 -0
  77. package/dist/merge.d.ts +1 -1
  78. package/dist/merge.d.ts.map +1 -1
  79. package/dist/merge.js +6 -12
  80. package/dist/merge.js.map +1 -1
  81. package/dist/migration-steps.d.ts +102 -0
  82. package/dist/migration-steps.d.ts.map +1 -0
  83. package/dist/migration-steps.js +293 -0
  84. package/dist/migration-steps.js.map +1 -0
  85. package/dist/query-validation.d.ts +68 -121
  86. package/dist/query-validation.d.ts.map +1 -1
  87. package/dist/query-validation.js +214 -380
  88. package/dist/query-validation.js.map +1 -1
  89. package/dist/record-changes.d.ts +11 -150
  90. package/dist/record-changes.d.ts.map +1 -1
  91. package/dist/record-changes.js +14 -305
  92. package/dist/record-changes.js.map +1 -1
  93. package/dist/schema.d.ts +18 -7
  94. package/dist/schema.d.ts.map +1 -1
  95. package/dist/schema.js +59 -30
  96. package/dist/schema.js.map +1 -1
  97. package/dist/scoped-stack/authority-snapshot.d.ts +37 -0
  98. package/dist/scoped-stack/authority-snapshot.d.ts.map +1 -0
  99. package/dist/scoped-stack/authority-snapshot.js +49 -0
  100. package/dist/scoped-stack/authority-snapshot.js.map +1 -0
  101. package/dist/scoped-stack/authority.d.ts +169 -0
  102. package/dist/scoped-stack/authority.d.ts.map +1 -0
  103. package/dist/scoped-stack/authority.js +408 -0
  104. package/dist/scoped-stack/authority.js.map +1 -0
  105. package/dist/scoped-stack/family-policy.d.ts +59 -0
  106. package/dist/scoped-stack/family-policy.d.ts.map +1 -0
  107. package/dist/scoped-stack/family-policy.js +68 -0
  108. package/dist/scoped-stack/family-policy.js.map +1 -0
  109. package/dist/scoped-stack/scoped-stack.d.ts +318 -0
  110. package/dist/scoped-stack/scoped-stack.d.ts.map +1 -0
  111. package/dist/scoped-stack/scoped-stack.js +695 -0
  112. package/dist/scoped-stack/scoped-stack.js.map +1 -0
  113. package/dist/scoped-stack/subscription.d.ts +36 -0
  114. package/dist/scoped-stack/subscription.d.ts.map +1 -0
  115. package/dist/scoped-stack/subscription.js +69 -0
  116. package/dist/scoped-stack/subscription.js.map +1 -0
  117. package/dist/stack/arguments.d.ts +27 -0
  118. package/dist/stack/arguments.d.ts.map +1 -0
  119. package/dist/stack/arguments.js +53 -0
  120. package/dist/stack/arguments.js.map +1 -0
  121. package/dist/stack/attachments.d.ts +24 -0
  122. package/dist/stack/attachments.d.ts.map +1 -0
  123. package/dist/stack/attachments.js +113 -0
  124. package/dist/stack/attachments.js.map +1 -0
  125. package/dist/{changes.d.ts → stack/changes.d.ts} +30 -72
  126. package/dist/stack/changes.d.ts.map +1 -0
  127. package/dist/{changes.js → stack/changes.js} +40 -74
  128. package/dist/stack/changes.js.map +1 -0
  129. package/dist/stack/client.d.ts +247 -0
  130. package/dist/stack/client.d.ts.map +1 -0
  131. package/dist/stack/client.js +9 -0
  132. package/dist/stack/client.js.map +1 -0
  133. package/dist/stack/identity-bindings.d.ts.map +1 -0
  134. package/dist/{identity-bindings.js → stack/identity-bindings.js} +5 -14
  135. package/dist/stack/identity-bindings.js.map +1 -0
  136. package/dist/stack/install-app.d.ts +22 -0
  137. package/dist/stack/install-app.d.ts.map +1 -0
  138. package/dist/stack/install-app.js +262 -0
  139. package/dist/stack/install-app.js.map +1 -0
  140. package/dist/stack/integrity.d.ts +94 -0
  141. package/dist/stack/integrity.d.ts.map +1 -0
  142. package/dist/stack/integrity.js +268 -0
  143. package/dist/stack/integrity.js.map +1 -0
  144. package/dist/{limits.d.ts → stack/limits.d.ts} +2 -5
  145. package/dist/stack/limits.d.ts.map +1 -0
  146. package/dist/{limits.js → stack/limits.js} +3 -6
  147. package/dist/stack/limits.js.map +1 -0
  148. package/dist/stack/migrations.d.ts +59 -0
  149. package/dist/stack/migrations.d.ts.map +1 -0
  150. package/dist/stack/migrations.js +141 -0
  151. package/dist/stack/migrations.js.map +1 -0
  152. package/dist/stack/reads.d.ts +69 -0
  153. package/dist/stack/reads.d.ts.map +1 -0
  154. package/dist/stack/reads.js +141 -0
  155. package/dist/stack/reads.js.map +1 -0
  156. package/dist/stack/record-id.d.ts +35 -0
  157. package/dist/stack/record-id.d.ts.map +1 -0
  158. package/dist/stack/record-id.js +82 -0
  159. package/dist/stack/record-id.js.map +1 -0
  160. package/dist/stack/stack.d.ts +444 -0
  161. package/dist/stack/stack.d.ts.map +1 -0
  162. package/dist/stack/stack.js +1339 -0
  163. package/dist/stack/stack.js.map +1 -0
  164. package/dist/stack/system-types.d.ts +16 -0
  165. package/dist/stack/system-types.d.ts.map +1 -0
  166. package/dist/stack/system-types.js +111 -0
  167. package/dist/stack/system-types.js.map +1 -0
  168. package/dist/stack/type-grants.d.ts +20 -0
  169. package/dist/stack/type-grants.d.ts.map +1 -0
  170. package/dist/stack/type-grants.js +69 -0
  171. package/dist/stack/type-grants.js.map +1 -0
  172. package/dist/stack/write-expectation.d.ts +79 -0
  173. package/dist/stack/write-expectation.d.ts.map +1 -0
  174. package/dist/stack/write-expectation.js +71 -0
  175. package/dist/stack/write-expectation.js.map +1 -0
  176. package/dist/stack/write-gate.d.ts +23 -0
  177. package/dist/stack/write-gate.d.ts.map +1 -0
  178. package/dist/stack/write-gate.js +17 -0
  179. package/dist/stack/write-gate.js.map +1 -0
  180. package/dist/testing.d.ts +44 -33
  181. package/dist/testing.d.ts.map +1 -1
  182. package/dist/testing.js +180 -176
  183. package/dist/testing.js.map +1 -1
  184. package/dist/type-handle.d.ts +67 -78
  185. package/dist/type-handle.d.ts.map +1 -1
  186. package/dist/type-handle.js +44 -111
  187. package/dist/type-handle.js.map +1 -1
  188. package/dist/types/adapter.d.ts +282 -0
  189. package/dist/types/adapter.d.ts.map +1 -0
  190. package/dist/types/adapter.js +5 -0
  191. package/dist/types/adapter.js.map +1 -0
  192. package/dist/types/associations.d.ts +140 -0
  193. package/dist/types/associations.d.ts.map +1 -0
  194. package/dist/types/associations.js +6 -0
  195. package/dist/types/associations.js.map +1 -0
  196. package/dist/types/capabilities.d.ts +89 -0
  197. package/dist/types/capabilities.d.ts.map +1 -0
  198. package/dist/types/capabilities.js +5 -0
  199. package/dist/types/capabilities.js.map +1 -0
  200. package/dist/types/events.d.ts +150 -0
  201. package/dist/types/events.d.ts.map +1 -0
  202. package/dist/types/events.js +5 -0
  203. package/dist/types/events.js.map +1 -0
  204. package/dist/types/ids.d.ts +43 -0
  205. package/dist/types/ids.d.ts.map +1 -0
  206. package/dist/types/ids.js +5 -0
  207. package/dist/types/ids.js.map +1 -0
  208. package/dist/types/index.d.ts +19 -0
  209. package/dist/types/index.d.ts.map +1 -0
  210. package/dist/types/index.js +19 -0
  211. package/dist/types/index.js.map +1 -0
  212. package/dist/types/journal.d.ts +47 -0
  213. package/dist/types/journal.d.ts.map +1 -0
  214. package/dist/types/journal.js +5 -0
  215. package/dist/types/journal.js.map +1 -0
  216. package/dist/types/query.d.ts +185 -0
  217. package/dist/types/query.d.ts.map +1 -0
  218. package/dist/types/query.js +10 -0
  219. package/dist/types/query.js.map +1 -0
  220. package/dist/types/records.d.ts +76 -0
  221. package/dist/types/records.d.ts.map +1 -0
  222. package/dist/types/records.js +5 -0
  223. package/dist/types/records.js.map +1 -0
  224. package/dist/types/schema.d.ts +74 -0
  225. package/dist/types/schema.d.ts.map +1 -0
  226. package/dist/types/schema.js +5 -0
  227. package/dist/types/schema.js.map +1 -0
  228. package/dist/types/system.d.ts +161 -0
  229. package/dist/types/system.d.ts.map +1 -0
  230. package/dist/types/system.js +32 -0
  231. package/dist/types/system.js.map +1 -0
  232. package/dist/types/tokens.d.ts +39 -0
  233. package/dist/types/tokens.d.ts.map +1 -0
  234. package/dist/types/tokens.js +5 -0
  235. package/dist/types/tokens.js.map +1 -0
  236. package/dist/validate.d.ts +31 -70
  237. package/dist/validate.d.ts.map +1 -1
  238. package/dist/validate.js +52 -92
  239. package/dist/validate.js.map +1 -1
  240. package/dist/{attachment-download.d.ts → wire/attachment-download.d.ts} +0 -57
  241. package/dist/wire/attachment-download.d.ts.map +1 -0
  242. package/dist/{attachment-download.js → wire/attachment-download.js} +0 -61
  243. package/dist/wire/attachment-download.js.map +1 -0
  244. package/dist/{auth.d.ts → wire/auth.d.ts} +5 -8
  245. package/dist/wire/auth.d.ts.map +1 -0
  246. package/dist/{auth.js → wire/auth.js} +5 -8
  247. package/dist/wire/auth.js.map +1 -0
  248. package/dist/{wire-body.d.ts → wire/body.d.ts} +35 -12
  249. package/dist/wire/body.d.ts.map +1 -0
  250. package/dist/{wire-body.js → wire/body.js} +74 -13
  251. package/dist/wire/body.js.map +1 -0
  252. package/dist/{wire-entry.d.ts → wire/index.d.ts} +12 -12
  253. package/dist/wire/index.d.ts.map +1 -0
  254. package/dist/{wire-entry.js → wire/index.js} +13 -17
  255. package/dist/wire/index.js.map +1 -0
  256. package/dist/wire/record.d.ts +31 -0
  257. package/dist/wire/record.d.ts.map +1 -0
  258. package/dist/{wire-record.js → wire/record.js} +7 -67
  259. package/dist/wire/record.js.map +1 -0
  260. package/dist/{wire-request.d.ts → wire/request.d.ts} +32 -49
  261. package/dist/wire/request.d.ts.map +1 -0
  262. package/dist/{wire-request.js → wire/request.js} +113 -83
  263. package/dist/wire/request.js.map +1 -0
  264. package/package.json +7 -7
  265. package/dist/adapter-entry.d.ts +0 -16
  266. package/dist/adapter-entry.d.ts.map +0 -1
  267. package/dist/adapter-entry.js +0 -14
  268. package/dist/adapter-entry.js.map +0 -1
  269. package/dist/attachment-download.d.ts.map +0 -1
  270. package/dist/attachment-download.js.map +0 -1
  271. package/dist/auth.d.ts.map +0 -1
  272. package/dist/auth.js.map +0 -1
  273. package/dist/changes.d.ts.map +0 -1
  274. package/dist/changes.js.map +0 -1
  275. package/dist/combine.d.ts.map +0 -1
  276. package/dist/combine.js.map +0 -1
  277. package/dist/did-entry.d.ts.map +0 -1
  278. package/dist/did-entry.js.map +0 -1
  279. package/dist/did.d.ts.map +0 -1
  280. package/dist/did.js.map +0 -1
  281. package/dist/identity-bindings.d.ts.map +0 -1
  282. package/dist/identity-bindings.js.map +0 -1
  283. package/dist/limits.d.ts.map +0 -1
  284. package/dist/limits.js.map +0 -1
  285. package/dist/record-id.d.ts +0 -70
  286. package/dist/record-id.d.ts.map +0 -1
  287. package/dist/record-id.js +0 -127
  288. package/dist/record-id.js.map +0 -1
  289. package/dist/scoped-stack.d.ts +0 -549
  290. package/dist/scoped-stack.d.ts.map +0 -1
  291. package/dist/scoped-stack.js +0 -1301
  292. package/dist/scoped-stack.js.map +0 -1
  293. package/dist/sort.d.ts.map +0 -1
  294. package/dist/sort.js.map +0 -1
  295. package/dist/stack-reads.d.ts +0 -38
  296. package/dist/stack-reads.d.ts.map +0 -1
  297. package/dist/stack-reads.js +0 -79
  298. package/dist/stack-reads.js.map +0 -1
  299. package/dist/stack.d.ts +0 -1032
  300. package/dist/stack.d.ts.map +0 -1
  301. package/dist/stack.js +0 -2337
  302. package/dist/stack.js.map +0 -1
  303. package/dist/types.d.ts +0 -1278
  304. package/dist/types.d.ts.map +0 -1
  305. package/dist/types.js +0 -51
  306. package/dist/types.js.map +0 -1
  307. package/dist/wire-body.d.ts.map +0 -1
  308. package/dist/wire-body.js.map +0 -1
  309. package/dist/wire-entry.d.ts.map +0 -1
  310. package/dist/wire-entry.js.map +0 -1
  311. package/dist/wire-record.d.ts +0 -50
  312. package/dist/wire-record.d.ts.map +0 -1
  313. package/dist/wire-record.js.map +0 -1
  314. package/dist/wire-request.d.ts.map +0 -1
  315. package/dist/wire-request.js.map +0 -1
  316. /package/dist/{did.d.ts → did/did.d.ts} +0 -0
  317. /package/dist/{identity-bindings.d.ts → stack/identity-bindings.d.ts} +0 -0
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Query and association validation
2
+ * Query validation
3
3
  * -------------------------------------------------------
4
- * The sanitizers a query passes through before any adapter sees it, plus
5
- * the relationship-target rules an association is held to. Both sides of
6
- * the same job: a discriminated union and a `QuerySort` field are compile-
7
- * time promises, and a server mapping a request body onto either supplies
8
- * raw JSON that no compiler has seen.
4
+ * The sanitizers a query passes through before any adapter sees it. A
5
+ * `QuerySort` field or a filter's discriminated union is a compile-time
6
+ * promise, and a server mapping a request onto either supplies raw JSON
7
+ * that no compiler has seen.
9
8
  *
10
9
  * These live in the invariant layer for the reason validation and
11
10
  * `_config` protection do — so no adapter can forget one. `assertQueryCapabilities`
@@ -16,43 +15,105 @@
16
15
  * the query — one rule, one answer.
17
16
  * See docs/spec/data-model.md § Capability-gated filters.
18
17
  */
19
- import { StackBadRequestError, StackValidationError } from './errors.js';
20
- import { familyIdProblem } from './schema.js';
21
- import { associationEqual, isAuthorityAssociation } from './record-changes.js';
22
- import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE } from './validate.js';
23
- import { NATIVE_SORT_FIELDS } from './types.js';
18
+ import { ARGUMENTS_INVALID, StackBadRequestError, StackValidationError } from './errors.js';
19
+ import { familyIdProblem, parseTypeId } from './schema.js';
20
+ import { targetErrors } from './associations/validation.js';
21
+ import { CONTENT_SEGMENT_METACHARACTERS, MAX_VALIDATION_DEPTH, SEGMENT_METACHARACTER_RE, isPlainObject, } from './validate.js';
22
+ import { NATIVE_SORT_FIELDS } from './types/index.js';
24
23
  /**
25
- * Fail loud rather than silently widen: a filter the adapter can't honor
26
- * would otherwise be dropped, returning an unfiltered superset presented
27
- * as the filtered result. Shared by Stack.query() and
28
- * APIAdapter.queryRecords(). See docs/spec/data-model.md
29
- * § Capability-gated filters.
24
+ * Fail loud rather than silently widen or reorder: a filter or sort the
25
+ * adapter can't honor would otherwise be dropped, answering with some
26
+ * other result, which a caller reading one bounded page cannot detect.
27
+ * Every refusal names the capability. The companion to resolveQuery(),
28
+ * which refuses the query's own shape; every entry point runs both. See
29
+ * docs/spec/data-model.md § Capability-gated filters.
30
30
  */
31
- export function assertQueryCapabilities(filter, capabilities) {
32
- const { content: reach, contentPresent, search } = capabilities.filter;
31
+ export function assertQueryCapabilities(query, capabilities) {
32
+ assertFilterCapability(query.filter, capabilities);
33
+ assertSortCapability(query.sort, capabilities);
34
+ }
35
+ function assertFilterCapability(filter, capabilities) {
36
+ const { content: reach, contentPresent, contentByType, search } = capabilities.filter;
33
37
  if (filter?.search && !search) {
34
38
  throw new StackBadRequestError('Query uses filter.search, but this adapter does not declare the filter.search capability.', 'filter.search');
35
39
  }
36
- const present = filter?.contentPresent?.length ? filter.contentPresent : undefined;
37
- if (!filter?.content && !present)
40
+ // An arm's keys are gated as the same keys at the top of the filter are.
41
+ const arms = Object.values(filter?.contentByType ?? {});
42
+ const parts = [filter ?? {}, ...arms];
43
+ const keys = parts.flatMap((part) => Object.keys(part.content ?? {}));
44
+ const present = parts.flatMap((part) => part.contentPresent ?? []);
45
+ if (!filter?.content && present.length === 0 && arms.length === 0)
38
46
  return;
39
47
  if (reach === 'none') {
40
48
  // Named as `filter.content` whichever key the query used: the reach
41
49
  // is what is absent, and it is the entry to look at in discovery.
42
- throw new StackBadRequestError(`Query uses ${present && !filter?.content ? 'filter.contentPresent' : 'filter.content'}, ` +
43
- 'but this adapter declares filter.content: "none".', 'filter.content');
50
+ const used = arms.length > 0
51
+ ? 'filter.contentByType'
52
+ : present.length > 0 && !filter?.content
53
+ ? 'filter.contentPresent'
54
+ : 'filter.content';
55
+ throw new StackBadRequestError(`Query uses ${used}, but this adapter declares filter.content: "none".`, 'filter.content');
56
+ }
57
+ if (arms.length > 0 && !contentByType) {
58
+ throw new StackBadRequestError('Query uses filter.contentByType, but this adapter does not declare the ' +
59
+ 'filter.contentByType capability.', 'filter.contentByType');
44
60
  }
45
- if (present && !contentPresent) {
61
+ if (present.length > 0 && !contentPresent) {
46
62
  throw new StackBadRequestError('Query uses filter.contentPresent, but this adapter does not declare the ' +
47
63
  'filter.contentPresent capability.', 'filter.contentPresent');
48
64
  }
49
- for (const key of [...Object.keys(filter?.content ?? {}), ...(present ?? [])]) {
65
+ for (const key of [...keys, ...present]) {
50
66
  if (parseContentFilterKey(key).length > 1 && reach !== 'path') {
51
67
  throw new StackBadRequestError(`Query uses the nested content path "${key}", but this adapter declares ` +
52
68
  `filter.content: "${reach}".`, 'filter.content');
53
69
  }
54
70
  }
55
71
  }
72
+ /**
73
+ * Hold `filter.contentByType` to a split along Types: at least one arm,
74
+ * each pinned to a TypeId and flat, and never beside a top-level content
75
+ * predicate, whose relation to the arms would be ambiguous. A map that
76
+ * could arrive empty would match everything once it crossed the wire. See
77
+ * docs/spec/data-model.md § Content by type.
78
+ */
79
+ export function assertValidContentByType(filter) {
80
+ const arms = filter?.contentByType;
81
+ if (arms === undefined)
82
+ return;
83
+ if (!isPlainObject(arms) || Object.keys(arms).length === 0) {
84
+ throw new StackBadRequestError('filter.contentByType must name at least one typeId.');
85
+ }
86
+ if (filter.content !== undefined || filter.contentPresent !== undefined) {
87
+ throw new StackBadRequestError('filter.contentByType cannot be combined with filter.content or filter.contentPresent.');
88
+ }
89
+ for (const [typeId, arm] of Object.entries(arms)) {
90
+ if (!parseTypeId(typeId)) {
91
+ throw new StackBadRequestError(`filter.contentByType key "${typeId}" is not a versioned TypeId.`);
92
+ }
93
+ if (!isPlainObject(arm)) {
94
+ throw new StackBadRequestError(`filter.contentByType["${typeId}"] must be an object.`);
95
+ }
96
+ for (const key of Object.keys(arm)) {
97
+ if (key !== 'content' && key !== 'contentPresent') {
98
+ throw new StackBadRequestError(`filter.contentByType["${typeId}"] takes content and contentPresent only, not "${key}".`);
99
+ }
100
+ }
101
+ if (arm.content !== undefined && !isPlainObject(arm.content)) {
102
+ throw new StackBadRequestError(`filter.contentByType["${typeId}"].content must be an object.`);
103
+ }
104
+ if (arm.contentPresent !== undefined &&
105
+ (!Array.isArray(arm.contentPresent) ||
106
+ !arm.contentPresent.every((path) => typeof path === 'string'))) {
107
+ throw new StackBadRequestError(`filter.contentByType["${typeId}"].contentPresent must be an array of strings.`);
108
+ }
109
+ for (const key of [
110
+ ...Object.keys(arm.content ?? {}),
111
+ ...(arm.contentPresent ?? []),
112
+ ]) {
113
+ parseContentFilterKey(key);
114
+ }
115
+ }
116
+ }
56
117
  /**
57
118
  * Whether a single-segment content filter can be pushed down to the
58
119
  * adapter. Callers that read a content field pair this with an in-memory
@@ -63,8 +124,13 @@ export function assertQueryCapabilities(filter, capabilities) {
63
124
  export function filtersContent(features) {
64
125
  return features.filter.content !== 'none';
65
126
  }
66
- /** The longest path both SQLite engines can execute — see the spec link below. */
67
- const MAX_CONTENT_PATH_SEGMENTS = 32;
127
+ /**
128
+ * The longest path that can reach a stored value: a top-level field sits at
129
+ * depth 0 and each segment descends one level, so a path past this reaches
130
+ * nothing a write accepts. See docs/spec/data-model.md § Nested content
131
+ * paths.
132
+ */
133
+ const MAX_CONTENT_PATH_SEGMENTS = MAX_VALIDATION_DEPTH + 1;
68
134
  /**
69
135
  * Split a content filter key into path segments. Write-time validation
70
136
  * keeps stored field names free of the path metacharacters, but a filter
@@ -73,9 +139,6 @@ const MAX_CONTENT_PATH_SEGMENTS = 32;
73
139
  */
74
140
  export function parseContentFilterKey(key) {
75
141
  const segments = key.split('.');
76
- // A SQLite adapter spends two json_each joins per segment against a
77
- // 64-table join limit, so a path past the cap is one the engine could
78
- // not execute. See docs/spec/data-model.md § Nested content paths.
79
142
  if (segments.length > MAX_CONTENT_PATH_SEGMENTS) {
80
143
  throw new StackBadRequestError(`Invalid content filter path "${key}": at most ${MAX_CONTENT_PATH_SEGMENTS} segments.`);
81
144
  }
@@ -100,357 +163,142 @@ const VALID_SORT_FIELDS = new Set(NATIVE_SORT_FIELDS);
100
163
  /** The only two sort directions; see assertValidSort. */
101
164
  const VALID_SORT_DIRECTIONS = new Set(['asc', 'desc']);
102
165
  /**
103
- * Reject a sort whose field or direction is outside the closed set the
104
- * types promise. `QuerySort` is typed `'asc' | 'desc'`, but a type is not a
105
- * runtime guard: a server mapping `?direction=` onto a query, or a
106
- * delegated app calling query(), supplies a raw string. A SQLite record
107
- * adapter interpolates the direction straight into `ORDER BY`, so an
108
- * unvalidated value there is a SQL-injection sink reachable from every
109
- * untrusted caller. Validating in the invariant layer — the same reason
110
- * emission and _config protection live here — means no adapter can forget
111
- * it. See docs/spec/data-model.md § Sorting and pagination.
166
+ * Reject a sort outside the closed set the types promise. A type is not a
167
+ * runtime guard, and a SQLite adapter interpolates the direction straight
168
+ * into `ORDER BY`, so an unvalidated one is an injection sink reachable by
169
+ * any untrusted caller. See docs/spec/data-model.md § Sorting and pagination.
112
170
  */
113
171
  export function assertValidSort(sort) {
114
172
  if (!sort)
115
173
  return;
116
- if (sort.field !== undefined && sort.contentField !== undefined) {
117
- throw new StackBadRequestError('A sort names either a native field or a content field, never both.');
174
+ const named = [sort.field, sort.contentField, sort.contentFieldByType].filter((member) => member !== undefined);
175
+ if (named.length !== 1) {
176
+ throw new StackBadRequestError('A sort names exactly one of a native field, a content field or a content field per type.');
118
177
  }
119
178
  if (sort.field !== undefined && !VALID_SORT_FIELDS.has(sort.field)) {
120
179
  throw new StackBadRequestError(`Invalid sort field "${sort.field}": expected one of ${NATIVE_SORT_FIELDS.join(', ')}.`);
121
180
  }
122
- if (sort.contentField !== undefined) {
123
- // Parsed by the same rule a filter key is, so a name a filter could
124
- // never address is not one a sort can either — then held to one
125
- // segment, because a value inside an array or object has no single
126
- // position to order its record by (docs/spec/data-model.md
127
- // § Sorting by a content field).
128
- if (parseContentFilterKey(sort.contentField).length > 1) {
129
- throw new StackBadRequestError(`Invalid sort content field "${sort.contentField}": sorting reaches top-level fields only.`);
130
- }
131
- }
181
+ if (sort.contentField !== undefined)
182
+ assertSortableField(sort.contentField);
183
+ if (sort.contentFieldByType !== undefined)
184
+ assertValidSortByType(sort.contentFieldByType);
132
185
  if (sort.direction !== undefined && !VALID_SORT_DIRECTIONS.has(sort.direction)) {
133
186
  throw new StackBadRequestError(`Invalid sort direction "${sort.direction}": expected "asc" or "desc".`);
134
187
  }
135
188
  }
136
189
  /**
137
- * The sort every adapter receives: a named sort with no `direction` is
138
- * ascending, resolved here so no adapter carries its own default. No sort
139
- * stays absent — the adapter's unsorted order is `createdAt` newest first,
140
- * and an explicit sort would be re-checked against the server's declared
141
- * `sort.fields`. See docs/spec/data-model.md § Sorting and pagination.
190
+ * Parsed by the same rule a filter key is, so a name a filter could never
191
+ * address is not one a sort can either — then held to one segment, because
192
+ * a value inside an array or object has no single position to order its
193
+ * record by (docs/spec/data-model.md § Sorting by a content field).
142
194
  */
143
- export function normalizeSort(sort) {
144
- if (!sort)
145
- return undefined;
146
- return { ...sort, direction: sort.direction ?? 'asc' };
147
- }
148
- /**
149
- * Fail loud rather than silently reorder: an adapter that can't honor the
150
- * requested sort would otherwise answer in some other order, which a
151
- * caller paging a bounded window has no way to notice. The companion to
152
- * assertQueryCapabilities(), split from it because a sort is not a filter
153
- * — see docs/spec/data-model.md § Capability-gated filters.
154
- */
155
- export function assertSortCapability(sort, capabilities) {
156
- if (!sort)
157
- return;
158
- if (sort.contentField !== undefined) {
159
- if (!capabilities.sort.contentField) {
160
- throw new StackBadRequestError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
161
- 'capability.', 'sort.contentField');
162
- }
163
- return;
195
+ const assertSortableField = (field) => {
196
+ if (typeof field !== 'string') {
197
+ throw new StackBadRequestError('Invalid sort content field: expected a string.');
164
198
  }
165
- const field = sort.field ?? 'createdAt';
166
- if (!capabilities.sort.fields.includes(field)) {
167
- throw new StackBadRequestError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`, 'sort.fields');
199
+ if (parseContentFilterKey(field).length > 1) {
200
+ throw new StackBadRequestError(`Invalid sort content field "${field}": sorting reaches top-level fields only.`);
168
201
  }
169
- }
170
- /** The identifier spaces a relationship target may name. */
171
- const TARGET_KINDS = new Set(['record', 'entity', 'external']);
172
- /**
173
- * The keys of one arm of a union, listed as an object so the compiler
174
- * holds the list to the type: a field added to the arm fails to compile
175
- * here until it is listed.
176
- */
177
- const keysOf = (keys) => Object.keys(keys);
178
- /** Every key each relationship target arm defines. */
179
- export const TARGET_KEYS = {
180
- record: keysOf({ kind: true, recordId: true, stackUrl: true }),
181
- entity: keysOf({ kind: true, entityId: true }),
182
- external: keysOf({ kind: true, ns: true, id: true }),
183
- };
184
- /** Every key each association kind defines. */
185
- const ASSOCIATION_KEYS = {
186
- tag: keysOf({ kind: true, label: true }),
187
- attachment: keysOf({
188
- kind: true,
189
- label: true,
190
- fileId: true,
191
- attachmentRecordId: true,
192
- }),
193
- relationship: keysOf({ kind: true, label: true, target: true }),
194
- permission: keysOf({ kind: true, label: true, grantee: true }),
195
- anyone: keysOf({ kind: true, label: true }),
196
- };
197
- /** Every key each grantee arm defines, the default tier included. */
198
- export const GRANTEE_KEYS = {
199
- entity: keysOf({ kind: true, entityId: true }),
200
- group: keysOf({ kind: true, groupId: true, role: true }),
201
- authenticated: keysOf({ kind: true }),
202
202
  };
203
203
  /**
204
- * The keys of `value` its arm does not define. An element carrying one is
205
- * refused rather than stored: an adapter keeps only the keys it has
206
- * columns for, so the rest would answer 200 and then vanish.
207
- * See docs/spec/data-model.md § Associations.
204
+ * Each key names a TypeId and orders by one field of it or one reduced
205
+ * constant. See docs/spec/data-model.md § Sorting by a content field.
208
206
  */
209
- export function unknownKeys(value, keys) {
210
- return Object.keys(value).filter((key) => !keys.includes(key));
211
- }
212
- /** unknownKeys() as a refusal. 400, not 422: the key addresses nothing. */
213
- export function assertKnownKeys(value, keys, path) {
214
- const unknown = unknownKeys(value, keys);
215
- if (unknown.length > 0)
216
- throw new StackBadRequestError(`Unknown key${unknown.length > 1 ? 's' : ''} in ${path}: ${unknown.join(', ')}`);
217
- }
218
- /**
219
- * Collect what makes a relationship target malformed. Absence is
220
- * meaningful on `stackUrl` and an external `id` — this stack, and the
221
- * whole namespace — so every part that names something must be non-empty:
222
- * an empty string stores and matches as though it were absent.
223
- * See docs/spec/data-model.md § Relationship targets.
224
- */
225
- function targetErrors(target, path, opts = {}) {
226
- const fail = (message) => [{ path, message }];
227
- if (!target || typeof target !== 'object')
228
- return fail('A relationship target must be an object.');
229
- if (!TARGET_KINDS.has(target.kind)) {
230
- return fail(`Unknown relationship target kind "${target.kind}": expected "record", "entity" or "external".`);
207
+ const assertValidSortByType = (byType) => {
208
+ if (!isPlainObject(byType) || Object.keys(byType).length === 0) {
209
+ throw new StackBadRequestError('sort.contentFieldByType must name at least one typeId.');
231
210
  }
232
- assertKnownKeys(target, TARGET_KEYS[target.kind], path);
233
- if (target.kind === 'record') {
234
- if (!target.recordId)
235
- return fail('A record target requires a non-empty recordId.');
236
- if (target.stackUrl !== undefined && !target.stackUrl) {
237
- return fail("A record target's stackUrl must be non-empty; omit it to name this stack.");
211
+ for (const [typeId, key] of Object.entries(byType)) {
212
+ if (!parseTypeId(typeId)) {
213
+ throw new StackBadRequestError(`sort.contentFieldByType key "${typeId}" is not a versioned TypeId.`);
214
+ }
215
+ const shape = isPlainObject(key) ? Object.keys(key) : [];
216
+ if (shape.length !== 1 || (shape[0] !== 'field' && shape[0] !== 'value')) {
217
+ throw new StackBadRequestError(`sort.contentFieldByType["${typeId}"] must be { field } or { value }.`);
218
+ }
219
+ const { field, value } = key;
220
+ if (shape[0] === 'field') {
221
+ assertSortableField(field);
222
+ }
223
+ else if (value !== null &&
224
+ typeof value !== 'string' &&
225
+ !(typeof value === 'number' && Number.isFinite(value))) {
226
+ throw new StackBadRequestError(`sort.contentFieldByType["${typeId}"].value must be a string, a finite number or null.`);
238
227
  }
239
- return [];
240
- }
241
- if (target.kind === 'entity') {
242
- return target.entityId ? [] : fail('An entity target requires a non-empty entityId.');
243
- }
244
- if (!target.ns)
245
- return fail('An external target requires a non-empty ns.');
246
- if (target.id === undefined) {
247
- return opts.externalIdOptional ? [] : fail('An external target requires an id.');
248
- }
249
- return target.id ? [] : fail("An external target's id must be non-empty when present.");
250
- }
251
- /**
252
- * Reject a relationship target outside the closed set the types promise.
253
- * A discriminated union is not a runtime guard — a server mapping a
254
- * request body onto an association supplies raw JSON — and an
255
- * unrecognized kind would otherwise be stored under the one arm that
256
- * names a Record in this stack. See docs/spec/data-model.md
257
- * § Relationship targets.
258
- *
259
- * A key the element's kind does not define is thrown as a 400 rather than
260
- * collected, at every depth: it addresses nothing, the way an unknown key
261
- * on a request body does. See docs/spec/data-model.md § Associations.
262
- */
263
- export function validateAssociation(association, path = 'association') {
264
- if (!namesKind(association)) {
265
- return [
266
- {
267
- path: `${path}.kind`,
268
- message: `Unknown association kind "${String(association?.kind)}": expected ${[...ASSOCIATION_KINDS].map((k) => `"${k}"`).join(', ')}.`,
269
- },
270
- ];
271
- }
272
- assertKnownKeys(association, ASSOCIATION_KEYS[association.kind], path);
273
- if (association.kind === 'permission') {
274
- return granteeErrors(association, `${path}.grantee`);
275
- }
276
- if (association.kind === 'anyone') {
277
- return association.label === 'read'
278
- ? []
279
- : [{ path: `${path}.label`, message: 'An `anyone` association carries only `read`.' }];
280
228
  }
281
- if (association.kind !== 'relationship')
282
- return [];
283
- return targetErrors(association.target, `${path}.target`);
284
- }
285
- /**
286
- * Who a duplicated permission names, since kind and label alone don't
287
- * distinguish two grants of the same bit to different grantees.
288
- */
289
- function granteeSuffix(association) {
290
- if (association.kind !== 'permission')
291
- return '';
292
- const g = association.grantee;
293
- return g.kind === 'entity' ? ` for "${g.entityId}"` : ` for ${g.role}s of "${g.groupId}"`;
294
- }
295
- /** The kinds an association may name — the closed set every surface reads. */
296
- const ASSOCIATION_KINDS = new Set(['tag', 'attachment', 'relationship', 'permission', 'anyone']);
229
+ };
230
+ /** The page a query reads when it names no `limit`. */
231
+ export const DEFAULT_QUERY_LIMIT = 50;
232
+ /** The largest page a query reads, whatever it asks for. */
233
+ export const MAX_QUERY_LIMIT = 1000;
297
234
  /**
298
- * Whether a value is an association at all. Asked ahead of the partition
299
- * checks, so a malformed element is named as one rather than reported as
300
- * the wrong surface for a kind it never had — a request body supplies raw
301
- * JSON, and `null` or `{}` is neither half of the partition.
235
+ * The query an adapter receives: its shape checked, and every default
236
+ * resolved so no adapter carries one — a named sort runs ascending, no
237
+ * sort is `createdAt` newest first, and the page is DEFAULT_QUERY_LIMIT
238
+ * up to MAX_QUERY_LIMIT. Shape refusals name no capability; see
239
+ * assertQueryCapabilities() for those that do. See docs/spec/data-model.md
240
+ * § Sorting and pagination.
302
241
  */
303
- function namesKind(association) {
304
- return (!!association &&
305
- typeof association === 'object' &&
306
- ASSOCIATION_KINDS.has(association.kind));
242
+ export function resolveQuery(query) {
243
+ const { sort, limit, ...rest } = query;
244
+ assertValidBaseIdFilter(query.filter);
245
+ assertValidContentByType(query.filter);
246
+ assertValidSort(sort);
247
+ assertWindowInteger(limit, 'limit', 1);
248
+ assertValidAssociationFilters(query.filter);
249
+ return {
250
+ ...rest,
251
+ sort: sort
252
+ ? { ...sort, direction: sort.direction ?? 'asc' }
253
+ : { field: 'createdAt', direction: 'desc' },
254
+ limit: Math.min(limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT),
255
+ };
307
256
  }
308
- /** The bits a permission element may name — the whole of its meaning. */
309
- const PERMISSION_LABELS = new Set(['read', 'write']);
310
257
  /**
311
- * Collect what makes a permission element malformed. `role` is required on
312
- * a group grantee: there is no "any member" default to fall back on, so an
313
- * absent one would otherwise widen or narrow silently depending on how the
314
- * reader broke the tie. See docs/spec/access-control.md
315
- * § Record-level permissions.
258
+ * Split a query at the door into what an adapter sees, resolved, and
259
+ * `presentAt`, which `Stack` applies to the results. Every check runs
260
+ * here, before the adapter is asked. See docs/spec/data-model.md
261
+ * § Sorting and pagination.
316
262
  */
317
- function granteeErrors(association, path) {
318
- const fail = (message) => [{ path, message }];
319
- if (!PERMISSION_LABELS.has(association.label)) {
320
- return [
321
- {
322
- path: `${path.slice(0, -'.grantee'.length)}.label`,
323
- message: `Unknown permission label "${association.label}": expected "read" or "write".`,
324
- },
325
- ];
326
- }
327
- const grantee = association.grantee;
328
- if (!grantee || typeof grantee !== 'object')
329
- return fail('A permission requires a grantee.');
330
- if (grantee.kind === 'entity' || grantee.kind === 'group')
331
- assertKnownKeys(grantee, GRANTEE_KEYS[grantee.kind], path);
332
- if (grantee.kind === 'entity') {
333
- return grantee.entityId ? [] : fail('An entity grantee requires a non-empty entityId.');
334
- }
335
- if (grantee.kind === 'group') {
336
- if (!grantee.groupId)
337
- return fail('A group grantee requires a non-empty groupId.');
338
- return grantee.role === 'member' || grantee.role === 'admin'
339
- ? []
340
- : fail('A group grantee requires a role of "member" or "admin".');
341
- }
342
- return fail(`Unknown permission grantee kind "${String(grantee.kind)}": expected "entity" or "group".`);
263
+ export function splitQuery(query) {
264
+ const { presentAt, ...plain } = query;
265
+ return {
266
+ query: resolveQuery(plain),
267
+ ...(presentAt !== undefined && { presentAt }),
268
+ };
343
269
  }
344
270
  /**
345
- * Refuse an authority element reaching the data half of the partition.
346
- * The two share a table, a delta shape and a durability tier; they never
347
- * share a call, because an app editing tags would otherwise be able to
348
- * replace an ACL it was never shown. Thrown rather than collected: the
349
- * caller named the wrong surface, not a malformed value, and the verb it
350
- * wanted is `grantAccess()`/`revokeAccess()`.
351
- *
352
- * Lives here, in the invariant layer, so an unscoped `Stack`, an import
353
- * and a server mapping a request body are all held to it.
354
- * See docs/spec/access-control.md § Record-level permissions.
271
+ * Hold a window bound — a page size, a cursor position — to an integer
272
+ * at or above `min`, the one shape every adapter reads alike: a negative
273
+ * `limit` drops entries from a JS slice but empties or lifts a SQL
274
+ * `LIMIT`. See docs/spec/data-model.md § Sorting and pagination.
355
275
  */
356
- export function assertDataAssociations(associations, surface) {
357
- const authority = associations.find((a) => namesKind(a) && isAuthorityAssociation(a));
358
- if (!authority)
276
+ export function assertWindowInteger(value, label, min) {
277
+ if (value === undefined)
359
278
  return;
360
- throw new StackBadRequestError(`${surface} does not carry authority: a "${authority.kind}" association belongs to the ` +
361
- '`permissions` surface — use grantAccess()/revokeAccess(), or the `permissions` change-set key.');
279
+ if (!Number.isInteger(value) || value < min) {
280
+ throw new StackBadRequestError(`Invalid ${label} ${String(value)}: expected ${min === 0 ? 'a non-negative' : 'a positive'} integer.`);
281
+ }
362
282
  }
363
- /**
364
- * The mirror of assertDataAssociations(): the `permissions` surface takes
365
- * authority kinds alone, so a tag routed through it is refused rather than
366
- * quietly becoming an ACL entry the `associations` projection never shows.
367
- */
368
- export function assertAuthorityAssociations(permissions, surface) {
369
- const data = permissions.find((a) => namesKind(a) && !isAuthorityAssociation(a));
370
- if (!data)
283
+ function assertSortCapability(sort, capabilities) {
284
+ if (sort.contentFieldByType !== undefined) {
285
+ if (!capabilities.sort.contentFieldByType) {
286
+ throw new StackBadRequestError('Query uses sort.contentFieldByType, but this adapter does not declare the ' +
287
+ 'sort.contentFieldByType capability.', 'sort.contentFieldByType');
288
+ }
371
289
  return;
372
- throw new StackBadRequestError(`${surface} carries authority alone: a "${data.kind}" association belongs to the ` +
373
- '`associations` surface — use associate()/dissociate(), or the `associations` change-set key.');
374
- }
375
- /** validateAssociation() over a create's `associations` array. */
376
- /**
377
- * Every association in a list, plus the one rule a list has that a single
378
- * association does not: **identities are distinct**. A list naming one
379
- * identity twice describes a state no store can hold — an adapter keys
380
- * associations by identity, so the second entry displaces the first — and
381
- * which of the two the record ends up with is a question the caller did
382
- * not mean to ask. Refused rather than collapsed, for the reason an empty
383
- * change set is refused: every way of producing one is a caller bug.
384
- * See docs/spec/data-model.md § Associations.
385
- */
386
- export function validateAssociations(associations, path = 'associations') {
387
- const list = associations ?? [];
388
- return [
389
- ...list.flatMap((a, i) => validateAssociation(a, `${path}[${i}]`)),
390
- ...list.flatMap((a, i) => list.slice(0, i).some((b) => associationEqual(a, b))
391
- ? [
392
- {
393
- path: `${path}[${i}]`,
394
- message: `Duplicate association identity: ${a.kind} "${a.label}"${granteeSuffix(a)} is named more than once.`,
395
- },
396
- ]
397
- : []),
398
- ];
399
- }
400
- /**
401
- * Hold an association edit list to the rules every verb shares: non-empty,
402
- * every element a known `op` over a well-formed association, one surface,
403
- * and each identity named once — a list that both removes and adds one
404
- * identity is the same ambiguity as naming it twice. `repoint` is the
405
- * journal's word, never a request. Asked of the whole list before anything
406
- * is read or written. See docs/spec/data-model.md § Mutations.
407
- */
408
- export function assertAssociationEdits(changes, surface, half) {
409
- if (!Array.isArray(changes) || changes.length === 0) {
410
- throw new StackValidationError([
411
- { path: 'changes', message: `${surface} names at least one change.` },
412
- ]);
413
290
  }
414
- const errors = [];
415
- changes.forEach((raw, i) => {
416
- const path = `changes[${i}]`;
417
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
418
- errors.push({ path, message: `${path} must be an object.` });
419
- return;
420
- }
421
- const edit = raw;
422
- if (edit.op !== 'repoint')
423
- assertKnownKeys(edit, ['op', 'association'], path);
424
- if (edit.op === 'repoint') {
425
- errors.push({
426
- path: `${path}.op`,
427
- message: 'op: "repoint" is recorded by the journal, not requested. ' +
428
- "Send { op: 'add', association }: an add naming an attachment the record already holds re-points it in place.",
429
- });
430
- }
431
- else if (edit.op !== 'add' && edit.op !== 'remove') {
432
- errors.push({ path: `${path}.op`, message: 'op must be "add" or "remove".' });
433
- }
434
- errors.push(...validateAssociation(edit.association, `${path}.association`));
435
- });
436
- if (errors.length > 0)
437
- throw new StackValidationError(errors);
438
- const associations = changes.map((c) => c.association);
439
- if (half === 'data')
440
- assertDataAssociations(associations, surface);
441
- else
442
- assertAuthorityAssociations(associations, surface);
443
- const duplicates = [];
444
- associations.forEach((a, i) => {
445
- if (associations.slice(0, i).some((b) => associationEqual(a, b))) {
446
- duplicates.push({
447
- path: `changes[${i}]`,
448
- message: `Duplicate association identity: ${a.kind} "${a.label}"${granteeSuffix(a)} is named more than once.`,
449
- });
291
+ if (sort.contentField !== undefined) {
292
+ if (!capabilities.sort.contentField) {
293
+ throw new StackBadRequestError('Query uses sort.contentField, but this adapter does not declare the sort.contentField ' +
294
+ 'capability.', 'sort.contentField');
450
295
  }
451
- });
452
- if (duplicates.length > 0)
453
- throw new StackValidationError(duplicates);
296
+ return;
297
+ }
298
+ const { field } = sort;
299
+ if (!capabilities.sort.fields.includes(field)) {
300
+ throw new StackBadRequestError(`Query sorts by "${field}", which this adapter does not declare in sort.fields.`, 'sort.fields');
301
+ }
454
302
  }
455
303
  /**
456
304
  * Reject an association filter that names neither of its halves, or whose
@@ -478,6 +326,17 @@ export function assertValidAssociationFilters(filter) {
478
326
  if (errors.length > 0)
479
327
  throw new StackBadRequestError(errors[0].message);
480
328
  }
329
+ /**
330
+ * Refuse a `baseId` argument that cannot name a type family, as an
331
+ * argument error prefixed with the `surface` it was passed to. See
332
+ * familyIdProblem().
333
+ */
334
+ export function assertFamilyId(baseId, surface) {
335
+ const problem = familyIdProblem(baseId, surface);
336
+ if (problem) {
337
+ throw new StackValidationError([{ path: 'baseId', message: problem }], ARGUMENTS_INVALID);
338
+ }
339
+ }
481
340
  /**
482
341
  * Refuse a `baseId` carrying an `@version` suffix. A family filter resolves
483
342
  * against registered families, so a TypeId there would match nothing and
@@ -491,48 +350,23 @@ export function assertValidBaseIdFilter(filter) {
491
350
  .filter((m) => m !== null)
492
351
  .map((message) => ({ path: 'filter.baseId', message }));
493
352
  if (errors.length > 0)
494
- throw new StackValidationError(errors);
353
+ throw new StackValidationError(errors, ARGUMENTS_INVALID);
495
354
  }
496
355
  /**
497
- * Hold a journal window to the shape both adapters can honor identically.
498
- *
499
- * Unvalidated, a negative `limit` diverges rather than failing: a JS
500
- * `slice(0, -1)` drops the newest entry, while SQLite reads a negative
501
- * LIMIT as "no ceiling" and returns the whole log. Neither is what the
502
- * caller asked for, and the disagreement is invisible until a stack
503
- * changes adapters. Refuse at the surface so no adapter has to guess.
504
- *
505
- * No ceiling is imposed: unlike a query, omitting `limit` reads the whole
506
- * log by contract, so a clamp would silently truncate exactly the caller
507
- * reconstructing an association's full history.
356
+ * A journal window: `afterSeq` 0 is the start of the log. No ceiling is
357
+ * imposed — omitting `limit` reads the whole log by contract, which a
358
+ * clamp would silently truncate.
508
359
  */
509
360
  export function assertValidJournalQuery(query) {
510
- if (!query)
511
- return;
512
- for (const key of ['afterSeq', 'limit']) {
513
- const value = query[key];
514
- if (value === undefined)
515
- continue;
516
- if (!Number.isInteger(value) || value < 0) {
517
- throw new StackBadRequestError(`Invalid journal ${key} ${String(value)}: expected a non-negative integer.`);
518
- }
519
- }
361
+ assertWindowInteger(query?.afterSeq, 'journal afterSeq', 0);
362
+ assertWindowInteger(query?.limit, 'journal limit', 1);
520
363
  }
521
364
  /**
522
- * Refuse a `getVersions()` window the adapters would read differently, as
523
- * assertValidJournalQuery() does for the journal. No ceiling is imposed:
524
- * omitting `limit` reads every version by contract.
365
+ * A `getVersions()` window. No ceiling is imposed: omitting `limit` reads
366
+ * every version by contract.
525
367
  */
526
368
  export function assertValidVersionsQuery(query) {
527
- if (!query)
528
- return;
529
- for (const key of ['beforeVersion', 'limit']) {
530
- const value = query[key];
531
- if (value === undefined)
532
- continue;
533
- if (!Number.isInteger(value) || value < 1) {
534
- throw new StackBadRequestError(`Invalid versions ${key} ${String(value)}: expected a positive integer.`);
535
- }
536
- }
369
+ assertWindowInteger(query?.beforeVersion, 'versions beforeVersion', 1);
370
+ assertWindowInteger(query?.limit, 'versions limit', 1);
537
371
  }
538
372
  //# sourceMappingURL=query-validation.js.map