@lovrozagar/oat 0.9.1 → 0.11.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 (283) hide show
  1. package/README.md +26 -1936
  2. package/dist/cli.js +326 -60
  3. package/dist/config/define-config.d.ts +15 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/validate.d.ts +12 -0
  7. package/dist/config/validate.d.ts.map +1 -0
  8. package/dist/config/validate.js +86 -0
  9. package/dist/config/validate.js.map +1 -0
  10. package/dist/conformance/effects.d.ts +9 -0
  11. package/dist/conformance/effects.d.ts.map +1 -0
  12. package/dist/conformance/effects.js +276 -0
  13. package/dist/conformance/effects.js.map +1 -0
  14. package/dist/conformance/expected-failures.d.ts +18 -0
  15. package/dist/conformance/expected-failures.d.ts.map +1 -0
  16. package/dist/conformance/expected-failures.js +48 -0
  17. package/dist/conformance/expected-failures.js.map +1 -0
  18. package/dist/conformance/feature-gate.d.ts +7 -0
  19. package/dist/conformance/feature-gate.d.ts.map +1 -0
  20. package/dist/conformance/feature-gate.js +428 -0
  21. package/dist/conformance/feature-gate.js.map +1 -0
  22. package/dist/conformance/fuzz.d.ts +108 -0
  23. package/dist/conformance/fuzz.d.ts.map +1 -0
  24. package/dist/conformance/fuzz.js +246 -0
  25. package/dist/conformance/fuzz.js.map +1 -0
  26. package/dist/conformance/leaks.d.ts +10 -0
  27. package/dist/conformance/leaks.d.ts.map +1 -0
  28. package/dist/conformance/leaks.js +23 -0
  29. package/dist/conformance/leaks.js.map +1 -0
  30. package/dist/conformance/pool.d.ts +49 -0
  31. package/dist/conformance/pool.d.ts.map +1 -0
  32. package/dist/conformance/pool.js +75 -0
  33. package/dist/conformance/pool.js.map +1 -0
  34. package/dist/conformance/rate-limit.d.ts +7 -0
  35. package/dist/conformance/rate-limit.d.ts.map +1 -0
  36. package/dist/conformance/rate-limit.js +372 -0
  37. package/dist/conformance/rate-limit.js.map +1 -0
  38. package/dist/conformance/scope.d.ts +15 -0
  39. package/dist/conformance/scope.d.ts.map +1 -0
  40. package/dist/conformance/scope.js +78 -0
  41. package/dist/conformance/scope.js.map +1 -0
  42. package/dist/conformance/seed-contract.d.ts +11 -0
  43. package/dist/conformance/seed-contract.d.ts.map +1 -0
  44. package/dist/conformance/seed-contract.js +680 -0
  45. package/dist/conformance/seed-contract.js.map +1 -0
  46. package/dist/conformance/shapes.d.ts +35 -0
  47. package/dist/conformance/shapes.d.ts.map +1 -0
  48. package/dist/conformance/shapes.js +162 -0
  49. package/dist/conformance/shapes.js.map +1 -0
  50. package/dist/conformance/specs.d.ts +21 -0
  51. package/dist/conformance/specs.d.ts.map +1 -0
  52. package/dist/conformance/specs.js +306 -0
  53. package/dist/conformance/specs.js.map +1 -0
  54. package/dist/conformance/suite.d.ts +209 -0
  55. package/dist/conformance/suite.d.ts.map +1 -0
  56. package/dist/conformance/suite.js +1538 -0
  57. package/dist/conformance/suite.js.map +1 -0
  58. package/dist/conformance/unique.d.ts +7 -0
  59. package/dist/conformance/unique.d.ts.map +1 -0
  60. package/dist/conformance/unique.js +586 -0
  61. package/dist/conformance/unique.js.map +1 -0
  62. package/dist/conformance/worker.d.ts +3 -0
  63. package/dist/conformance/worker.d.ts.map +1 -0
  64. package/dist/conformance/worker.js +27 -0
  65. package/dist/conformance/worker.js.map +1 -0
  66. package/dist/index.d.ts +10 -3
  67. package/dist/index.js +6 -2
  68. package/dist/reference/defects.d.ts +71 -1
  69. package/dist/reference/defects.d.ts.map +1 -1
  70. package/dist/reference/defects.js +74 -8
  71. package/dist/reference/defects.js.map +1 -1
  72. package/dist/reference/dialect.d.ts +6 -4
  73. package/dist/reference/dialect.d.ts.map +1 -1
  74. package/dist/reference/dialect.js +1 -0
  75. package/dist/reference/dialect.js.map +1 -1
  76. package/dist/reference/http.d.ts +17 -25
  77. package/dist/reference/http.d.ts.map +1 -1
  78. package/dist/reference/http.js +574 -224
  79. package/dist/reference/http.js.map +1 -1
  80. package/dist/reference/listing.d.ts +51 -0
  81. package/dist/reference/listing.d.ts.map +1 -0
  82. package/dist/reference/listing.js +256 -0
  83. package/dist/reference/listing.js.map +1 -0
  84. package/dist/reference/model.d.ts +35 -1
  85. package/dist/reference/model.d.ts.map +1 -1
  86. package/dist/reference/model.js +42 -0
  87. package/dist/reference/model.js.map +1 -1
  88. package/dist/reference/query.d.ts +132 -43
  89. package/dist/reference/query.d.ts.map +1 -1
  90. package/dist/reference/query.js +397 -315
  91. package/dist/reference/query.js.map +1 -1
  92. package/dist/reference/shapes.d.ts +60 -0
  93. package/dist/reference/shapes.d.ts.map +1 -0
  94. package/dist/reference/shapes.js +142 -0
  95. package/dist/reference/shapes.js.map +1 -0
  96. package/dist/reference/spec.d.ts +9 -2
  97. package/dist/reference/spec.d.ts.map +1 -1
  98. package/dist/reference/spec.js +291 -71
  99. package/dist/reference/spec.js.map +1 -1
  100. package/dist/reference/store-api.d.ts +14 -43
  101. package/dist/reference/store-api.d.ts.map +1 -1
  102. package/dist/reference/store-api.js +8 -58
  103. package/dist/reference/store-api.js.map +1 -1
  104. package/dist/reference/stores/memory.d.ts +12 -15
  105. package/dist/reference/stores/memory.d.ts.map +1 -1
  106. package/dist/reference/stores/memory.js +22 -70
  107. package/dist/reference/stores/memory.js.map +1 -1
  108. package/dist/reference/stores/postgres.d.ts +11 -23
  109. package/dist/reference/stores/postgres.d.ts.map +1 -1
  110. package/dist/reference/stores/postgres.js +53 -334
  111. package/dist/reference/stores/postgres.js.map +1 -1
  112. package/dist/reference/stores/sql-compile.d.ts +36 -0
  113. package/dist/reference/stores/sql-compile.d.ts.map +1 -0
  114. package/dist/reference/stores/sql-compile.js +95 -0
  115. package/dist/reference/stores/sql-compile.js.map +1 -0
  116. package/dist/reference/stores/sqlite.d.ts +12 -27
  117. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  118. package/dist/reference/stores/sqlite.js +54 -355
  119. package/dist/reference/stores/sqlite.js.map +1 -1
  120. package/dist/report/console.d.ts +2 -1
  121. package/dist/report/console.d.ts.map +1 -1
  122. package/dist/report/console.js +56 -10
  123. package/dist/report/console.js.map +1 -1
  124. package/dist/report/matrix.d.ts +10 -13
  125. package/dist/report/matrix.d.ts.map +1 -1
  126. package/dist/report/matrix.js +82 -18
  127. package/dist/report/matrix.js.map +1 -1
  128. package/dist/report/render.d.ts +18 -1
  129. package/dist/report/render.d.ts.map +1 -1
  130. package/dist/report/render.js +169 -14
  131. package/dist/report/render.js.map +1 -1
  132. package/dist/runtime/async.d.ts +16 -3
  133. package/dist/runtime/async.d.ts.map +1 -1
  134. package/dist/runtime/async.js +24 -19
  135. package/dist/runtime/async.js.map +1 -1
  136. package/dist/runtime/auth.d.ts +2 -9
  137. package/dist/runtime/auth.d.ts.map +1 -1
  138. package/dist/runtime/auth.js +38 -62
  139. package/dist/runtime/auth.js.map +1 -1
  140. package/dist/runtime/body.d.ts +15 -3
  141. package/dist/runtime/body.d.ts.map +1 -1
  142. package/dist/runtime/body.js +61 -12
  143. package/dist/runtime/body.js.map +1 -1
  144. package/dist/runtime/check-costs.d.ts +15 -0
  145. package/dist/runtime/check-costs.d.ts.map +1 -0
  146. package/dist/runtime/check-costs.js +114 -0
  147. package/dist/runtime/check-costs.js.map +1 -0
  148. package/dist/runtime/check-ids.d.ts +15 -0
  149. package/dist/runtime/check-ids.d.ts.map +1 -0
  150. package/dist/runtime/check-ids.js +140 -0
  151. package/dist/runtime/check-ids.js.map +1 -0
  152. package/dist/runtime/checks.d.ts +122 -18
  153. package/dist/runtime/checks.d.ts.map +1 -1
  154. package/dist/runtime/checks.js +3437 -1933
  155. package/dist/runtime/checks.js.map +1 -1
  156. package/dist/runtime/client.d.ts +112 -0
  157. package/dist/runtime/client.d.ts.map +1 -1
  158. package/dist/runtime/client.js +271 -39
  159. package/dist/runtime/client.js.map +1 -1
  160. package/dist/runtime/cookies.d.ts +16 -2
  161. package/dist/runtime/cookies.d.ts.map +1 -1
  162. package/dist/runtime/cookies.js +83 -22
  163. package/dist/runtime/cookies.js.map +1 -1
  164. package/dist/runtime/exchanges.d.ts +4 -5
  165. package/dist/runtime/exchanges.d.ts.map +1 -1
  166. package/dist/runtime/exchanges.js +33 -55
  167. package/dist/runtime/exchanges.js.map +1 -1
  168. package/dist/runtime/exit.d.ts +40 -0
  169. package/dist/runtime/exit.d.ts.map +1 -0
  170. package/dist/runtime/exit.js +48 -0
  171. package/dist/runtime/exit.js.map +1 -0
  172. package/dist/runtime/feature-gate.d.ts +0 -9
  173. package/dist/runtime/feature-gate.d.ts.map +1 -1
  174. package/dist/runtime/feature-gate.js +0 -21
  175. package/dist/runtime/feature-gate.js.map +1 -1
  176. package/dist/runtime/finding.d.ts +61 -7
  177. package/dist/runtime/finding.d.ts.map +1 -1
  178. package/dist/runtime/finding.js +74 -12
  179. package/dist/runtime/finding.js.map +1 -1
  180. package/dist/runtime/fixture.d.ts +35 -28
  181. package/dist/runtime/fixture.d.ts.map +1 -1
  182. package/dist/runtime/fixture.js +92 -731
  183. package/dist/runtime/fixture.js.map +1 -1
  184. package/dist/runtime/generate.d.ts +63 -0
  185. package/dist/runtime/generate.d.ts.map +1 -0
  186. package/dist/runtime/generate.js +635 -0
  187. package/dist/runtime/generate.js.map +1 -0
  188. package/dist/runtime/headers.d.ts +6 -0
  189. package/dist/runtime/headers.d.ts.map +1 -0
  190. package/dist/runtime/headers.js +16 -0
  191. package/dist/runtime/headers.js.map +1 -0
  192. package/dist/runtime/path.d.ts +7 -0
  193. package/dist/runtime/path.d.ts.map +1 -0
  194. package/dist/runtime/path.js +44 -0
  195. package/dist/runtime/path.js.map +1 -0
  196. package/dist/runtime/payloads.d.ts +2 -0
  197. package/dist/runtime/payloads.d.ts.map +1 -1
  198. package/dist/runtime/payloads.js +2 -1
  199. package/dist/runtime/payloads.js.map +1 -1
  200. package/dist/runtime/rate-limit.d.ts +6 -4
  201. package/dist/runtime/rate-limit.d.ts.map +1 -1
  202. package/dist/runtime/rate-limit.js +22 -33
  203. package/dist/runtime/rate-limit.js.map +1 -1
  204. package/dist/runtime/redact.d.ts +24 -0
  205. package/dist/runtime/redact.d.ts.map +1 -0
  206. package/dist/runtime/redact.js +98 -0
  207. package/dist/runtime/redact.js.map +1 -0
  208. package/dist/runtime/run.d.ts +51 -2
  209. package/dist/runtime/run.d.ts.map +1 -1
  210. package/dist/runtime/run.js +463 -195
  211. package/dist/runtime/run.js.map +1 -1
  212. package/dist/runtime/runs.d.ts +5 -0
  213. package/dist/runtime/runs.d.ts.map +1 -1
  214. package/dist/runtime/runs.js +7 -1
  215. package/dist/runtime/runs.js.map +1 -1
  216. package/dist/runtime/scope.d.ts +163 -0
  217. package/dist/runtime/scope.d.ts.map +1 -0
  218. package/dist/runtime/scope.js +423 -0
  219. package/dist/runtime/scope.js.map +1 -0
  220. package/dist/runtime/teardown.d.ts +17 -4
  221. package/dist/runtime/teardown.d.ts.map +1 -1
  222. package/dist/runtime/teardown.js +49 -16
  223. package/dist/runtime/teardown.js.map +1 -1
  224. package/dist/runtime/transcript.d.ts +34 -2
  225. package/dist/runtime/transcript.d.ts.map +1 -1
  226. package/dist/runtime/transcript.js +119 -24
  227. package/dist/runtime/transcript.js.map +1 -1
  228. package/dist/runtime/unique.d.ts +2 -11
  229. package/dist/runtime/unique.d.ts.map +1 -1
  230. package/dist/runtime/unique.js +27 -53
  231. package/dist/runtime/unique.js.map +1 -1
  232. package/dist/runtime/validate.d.ts +13 -2
  233. package/dist/runtime/validate.d.ts.map +1 -1
  234. package/dist/runtime/validate.js +51 -31
  235. package/dist/runtime/validate.js.map +1 -1
  236. package/dist/runtime/wait.d.ts +2 -1
  237. package/dist/runtime/wait.d.ts.map +1 -1
  238. package/dist/runtime/wait.js +2 -36
  239. package/dist/runtime/wait.js.map +1 -1
  240. package/dist/runtime/world.d.ts +31 -3
  241. package/dist/runtime/world.d.ts.map +1 -1
  242. package/dist/runtime/world.js +113 -32
  243. package/dist/runtime/world.js.map +1 -1
  244. package/dist/spec/collection.d.ts +8 -2
  245. package/dist/spec/collection.d.ts.map +1 -1
  246. package/dist/spec/collection.js +47 -7
  247. package/dist/spec/collection.js.map +1 -1
  248. package/dist/spec/extensions.d.ts +5 -1
  249. package/dist/spec/extensions.d.ts.map +1 -1
  250. package/dist/spec/extensions.js +9 -2
  251. package/dist/spec/extensions.js.map +1 -1
  252. package/dist/spec/graph.d.ts +46 -0
  253. package/dist/spec/graph.d.ts.map +1 -1
  254. package/dist/spec/graph.js +175 -11
  255. package/dist/spec/graph.js.map +1 -1
  256. package/dist/spec/load.d.ts +25 -5
  257. package/dist/spec/load.d.ts.map +1 -1
  258. package/dist/spec/load.js +160 -59
  259. package/dist/spec/load.js.map +1 -1
  260. package/dist/spec/query-capabilities.d.ts +4 -0
  261. package/dist/spec/query-capabilities.d.ts.map +1 -1
  262. package/dist/spec/query-capabilities.js +6 -0
  263. package/dist/spec/query-capabilities.js.map +1 -1
  264. package/dist/spec/schema.d.ts +20 -0
  265. package/dist/spec/schema.d.ts.map +1 -0
  266. package/dist/spec/schema.js +118 -0
  267. package/dist/spec/schema.js.map +1 -0
  268. package/dist/spec/tag-schema.d.ts +12 -0
  269. package/dist/spec/tag-schema.d.ts.map +1 -0
  270. package/dist/spec/tag-schema.js +143 -0
  271. package/dist/spec/tag-schema.js.map +1 -0
  272. package/dist/spec/types.js +17 -1
  273. package/dist/spec/types.js.map +1 -1
  274. package/docs/api.md +59 -0
  275. package/docs/assumptions.md +99 -0
  276. package/docs/checks.md +183 -0
  277. package/docs/commands.md +214 -0
  278. package/docs/configuration.md +650 -0
  279. package/docs/model.md +184 -0
  280. package/docs/reference-defects.md +94 -0
  281. package/docs/reports.md +211 -0
  282. package/docs/tags.md +400 -0
  283. package/package.json +3 -1
@@ -5,19 +5,53 @@
5
5
  * heuristic fallbacks. The conformance suite also serves stripped variants to test the fallbacks.
6
6
  */
7
7
  import { POSTGREST } from "./dialect.js";
8
- import { ENTITIES, JOB, TABLE, fieldsWhere, writableFields } from "./model.js";
8
+ import { ENTITIES, fieldsWhere, writableFields } from "./model.js";
9
+ const DEFAULT_CONTEXT = { entities: ENTITIES, shape: {} };
10
+ function entityNamed(ctx, name) {
11
+ return ctx.entities.find((entity) => entity.name === name);
12
+ }
9
13
  function fieldSchema(field) {
10
- const base = { type: field.type };
14
+ const base = field.ref === undefined ? { type: field.type } : { $ref: `#/components/schemas/${field.ref}` };
15
+ if (field.type === "array") {
16
+ base.items = { enum: [...(field.items ?? [])], type: "string" };
17
+ base.maxItems = 5;
18
+ }
19
+ if (field.format !== undefined)
20
+ base.format = field.format;
21
+ if (field.minimum !== undefined)
22
+ base.minimum = field.minimum;
23
+ if (field.maximum !== undefined)
24
+ base.maximum = field.maximum;
25
+ if (field.multipleOf !== undefined)
26
+ base.multipleOf = field.multipleOf;
27
+ if (field.const !== undefined)
28
+ base.const = field.const;
11
29
  if (field.enum !== undefined)
12
30
  base.enum = [...field.enum];
13
31
  if (field.maxLength !== undefined)
14
32
  base.maxLength = field.maxLength;
15
33
  if (field.generated === true)
16
34
  base.readOnly = true;
35
+ if (field.writeOnly === true)
36
+ base.writeOnly = true;
17
37
  if (field.nullable === true)
18
38
  return { oneOf: [base, { type: "null" }] };
19
39
  return base;
20
40
  }
41
+ /** The schema of an identifier in a path, matching the identity it names. */
42
+ function idSchema(ctx, param) {
43
+ const owner = ctx.entities.find((entity) => entity.itemParam === param);
44
+ const identity = owner?.fields.find((field) => field.name === owner.identity);
45
+ if (identity?.type === "integer")
46
+ return { minimum: 1, type: "integer" };
47
+ if (identity?.format === "uuid")
48
+ return { format: "uuid", type: "string" };
49
+ return { type: "string" };
50
+ }
51
+ /** A success status as the document spells it — exactly, or as the `2XX` range. */
52
+ function ok(ctx, status) {
53
+ return ctx.shape.rangeStatuses === true ? "2XX" : String(status);
54
+ }
21
55
  function itemSchema(entity) {
22
56
  const properties = {};
23
57
  for (const field of entity.fields)
@@ -29,15 +63,23 @@ function itemSchema(entity) {
29
63
  type: "object",
30
64
  };
31
65
  }
32
- function bodySchema(entity, phase) {
66
+ /** An entity's record schema — inline, or a reference into `components` under the layout shape. */
67
+ function itemSchemaRef(ctx, entity) {
68
+ return ctx.shape.specLayout === true ? { $ref: `#/components/schemas/${schemaName(entity)}` } : itemSchema(entity);
69
+ }
70
+ function schemaName(entity) {
71
+ return `${entity.name[0]?.toUpperCase() ?? ""}${entity.name.slice(1)}`;
72
+ }
73
+ function bodySchema(entity, phase, replace = false) {
33
74
  const properties = {};
34
75
  for (const field of writableFields(entity, phase))
35
76
  properties[field.name] = fieldSchema(field);
36
77
  return {
37
78
  additionalProperties: false,
38
79
  properties,
39
- required: phase === "create"
40
- ? writableFields(entity, "create")
80
+ /* A create, and a replacing update, must carry every required field; a PATCH need not. */
81
+ required: phase === "create" || replace
82
+ ? writableFields(entity, phase)
41
83
  .filter((f) => f.required === true)
42
84
  .map((f) => f.name)
43
85
  : [],
@@ -48,25 +90,26 @@ function bodySchema(entity, phase) {
48
90
  function collectionKey(entity, dialect) {
49
91
  return dialect.envelope?.collection ?? entity.plural;
50
92
  }
51
- function listSchema(entity, dialect) {
93
+ function listSchema(ctx, entity, dialect) {
52
94
  const env = dialect.envelope;
53
95
  /* No envelope: the response *is* the array. A document that says so is the only place oat can
54
96
  * learn it, so the schema has to say it rather than describe a wrapper that does not exist. */
55
97
  if (env === null)
56
- return { items: itemSchema(entity), type: "array" };
98
+ return { items: itemSchemaRef(ctx, entity), type: "array" };
57
99
  const key = collectionKey(entity, dialect);
58
- const properties = {
59
- [env.hasMore]: { type: "boolean" },
60
- [env.limit]: { minimum: 1, type: "integer" },
61
- [env.page]: { oneOf: [{ minimum: 1, type: "integer" }, { type: "null" }] },
62
- [env.total]: { minimum: 0, type: "integer" },
63
- [key]: { items: itemSchema(entity), type: "array" },
100
+ const properties = { [key]: { items: itemSchemaRef(ctx, entity), type: "array" } };
101
+ const required = [key];
102
+ const declare = (name, schema) => {
103
+ if (name === undefined)
104
+ return;
105
+ properties[name] = schema;
106
+ required.push(name);
64
107
  };
65
- const required = [env.hasMore, env.limit, env.page, env.total, key];
66
- if (env.nextCursor !== undefined) {
67
- properties[env.nextCursor] = { oneOf: [{ type: "string" }, { type: "null" }] };
68
- required.push(env.nextCursor);
69
- }
108
+ declare(env.hasMore, { type: "boolean" });
109
+ declare(env.limit, { minimum: 1, type: "integer" });
110
+ declare(env.page, { oneOf: [{ minimum: 1, type: "integer" }, { type: "null" }] });
111
+ declare(env.total, { minimum: 0, type: "integer" });
112
+ declare(env.nextCursor, { oneOf: [{ type: "string" }, { type: "null" }] });
70
113
  return { additionalProperties: false, properties, required, type: "object" };
71
114
  }
72
115
  function errorSchema(status, key) {
@@ -90,7 +133,16 @@ const ERRORS = [
90
133
  [409, "conflict"],
91
134
  [415, "unsupported_media_type"],
92
135
  ];
93
- function errorResponses(codes) {
136
+ function errorResponses(ctx, codes) {
137
+ /* A document may describe every failure at once: one `default` response and one schema. */
138
+ if (ctx.shape.rangeStatuses === true) {
139
+ return {
140
+ default: {
141
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
142
+ description: "error",
143
+ },
144
+ };
145
+ }
94
146
  const out = {};
95
147
  for (const [status, key] of ERRORS) {
96
148
  if (!codes.includes(status))
@@ -169,6 +221,13 @@ function listQueryParams(dialect, entity) {
169
221
  { type: "string" },
170
222
  "Comma-separated sparse fieldset; * selects all",
171
223
  ], [p.search, { maxLength: 200, type: "string" }, "Free-text search across searchable fields"]);
224
+ if (p.searchMode !== undefined) {
225
+ params.push([
226
+ p.searchMode,
227
+ { default: "contains", enum: ["contains", "prefix"], type: "string" },
228
+ "How the search term matches: anywhere in a field, or at its start",
229
+ ]);
230
+ }
172
231
  /* Exactly one paging model is published, because that is what a real document does — and a
173
232
  * document advertising a parameter the backend ignores is itself a defect oat reports. */
174
233
  if (p.page !== undefined) {
@@ -182,16 +241,30 @@ function listQueryParams(dialect, entity) {
182
241
  }
183
242
  return params;
184
243
  }
185
- function pathParams(entity, includeItem) {
244
+ /** A canonical `field.dir` order spelled in the dialect's own sort grammar. */
245
+ function renderOrder(dialect, canonical) {
246
+ const [field = "", direction = "asc"] = canonical.split(".");
247
+ switch (dialect.sortGrammar ?? "dotted") {
248
+ case "prefixed":
249
+ return direction === "desc" ? `-${field}` : field;
250
+ case "colon":
251
+ return `${field}:${direction}`;
252
+ case "spaced":
253
+ return `${field} ${direction}`;
254
+ case "dotted":
255
+ return `${field}.${direction}`;
256
+ }
257
+ }
258
+ function pathParams(ctx, entity, includeItem) {
186
259
  const params = entity.parents.map((name) => ({
187
260
  in: "path",
188
261
  name,
189
262
  required: true,
190
- schema: { type: "string" },
263
+ schema: idSchema(ctx, name),
191
264
  ...(name === "project_id" ? { "x-root": true } : {}),
192
265
  }));
193
266
  if (includeItem) {
194
- params.push({ in: "path", name: entity.itemParam, required: true, schema: { type: "string" } });
267
+ params.push({ in: "path", name: entity.itemParam, required: true, schema: idSchema(ctx, entity.itemParam) });
195
268
  }
196
269
  return params;
197
270
  }
@@ -218,15 +291,15 @@ function queryFieldType(field) {
218
291
  * row write genuinely changes the table's representation. Declaring routes that a write does not
219
292
  * actually affect would make the invalidation check assert something false.
220
293
  */
221
- function parentReadRoutes(entity) {
294
+ function parentReadRoutes(ctx, entity) {
222
295
  if (entity.name !== "row")
223
296
  return [];
224
- const table = ENTITIES.find((candidate) => candidate.name === "table");
297
+ const table = ctx.entities.find((candidate) => candidate.name === "table");
225
298
  if (table === undefined)
226
299
  return [];
227
300
  return [`GET ${table.collectionPath}`, `GET ${table.itemPath}`];
228
301
  }
229
- function buildEntityPaths(entity, dialect) {
302
+ function buildEntityPaths(ctx, entity, dialect) {
230
303
  const listRoute = `GET ${entity.collectionPath}`;
231
304
  const itemRoute = `GET ${entity.itemPath}`;
232
305
  const surface = [listRoute, itemRoute];
@@ -260,6 +333,23 @@ function buildEntityPaths(entity, dialect) {
260
333
  return row;
261
334
  }),
262
335
  stableTiebreak: entity.identity,
336
+ /* Search semantics stated rather than left to inference: case-insensitive, and a blank
337
+ * term matches everything. */
338
+ searchCase: "insensitive",
339
+ searchEmpty: "match-all",
340
+ sort: { defaultOrder: renderOrder(dialect, entity.defaultOrder) },
341
+ ...(dialect.params.searchMode === undefined ? {} : { searchModes: ["contains", "prefix"] }),
342
+ ...(entity.relations === undefined
343
+ ? {}
344
+ : {
345
+ select: {
346
+ nested: true,
347
+ relations: Object.entries(entity.relations).map(([name, relation]) => ({
348
+ fields: [...relation.fields],
349
+ name,
350
+ })),
351
+ },
352
+ }),
263
353
  ...(catalog === undefined
264
354
  ? {}
265
355
  : {
@@ -267,7 +357,6 @@ function buildEntityPaths(entity, dialect) {
267
357
  emptyIn: catalog.emptyIn,
268
358
  maxFilterConditions: catalog.maxFilterConditions,
269
359
  maxInValues: catalog.maxInValues,
270
- searchEmpty: "match-all",
271
360
  selectUnknown: catalog.selectUnknown,
272
361
  sortNulls: ["first", "last"],
273
362
  maxSortKeys: 3,
@@ -277,7 +366,7 @@ function buildEntityPaths(entity, dialect) {
277
366
  get: {
278
367
  operationId: `${entity.name}.list`,
279
368
  parameters: [
280
- ...pathParams(entity, false),
369
+ ...pathParams(ctx, entity, false),
281
370
  ...listQueryParams(dialect, entity).map(([name, schema, description]) => ({
282
371
  description,
283
372
  in: "query",
@@ -293,20 +382,22 @@ function buildEntityPaths(entity, dialect) {
293
382
  },
294
383
  ],
295
384
  responses: {
296
- "200": jsonResponse(`List ${entity.plural}`, listSchema(entity, dialect), listResponseHeaders(dialect)),
297
- ...errorResponses([400, 401, 403, 404]),
385
+ [ok(ctx, 200)]: jsonResponse(`List ${entity.plural}`, listSchema(ctx, entity, dialect), listResponseHeaders(dialect)),
386
+ ...errorResponses(ctx, [400, 401, 403, 404]),
298
387
  },
299
388
  summary: `List ${entity.plural}`,
300
389
  tags: [title],
301
390
  "x-entity": { action: "list", identity: entity.identity, name: entity.name },
302
391
  "x-query": query,
392
+ /* Generous enough never to slow a run; declared so a stricter real limit is testable. */
393
+ "x-rate-limit": { category: `${entity.name}.list`, rps: 500 },
303
394
  "x-tenant": tenantParam(entity),
304
395
  ...(entity.softDeleteField === undefined ? {} : { "x-soft-delete": entity.softDeleteField }),
305
396
  },
306
397
  post: {
307
398
  operationId: `${entity.name}.create`,
308
399
  parameters: [
309
- ...pathParams(entity, false),
400
+ ...pathParams(ctx, entity, false),
310
401
  /* Declared as an ordinary header parameter, because that is how real APIs publish
311
402
  * it. oat needs no new meta tag to find this: a create operation naming a header
312
403
  * whose name reads as an idempotency key is enough to know replay is promised. */
@@ -320,12 +411,16 @@ function buildEntityPaths(entity, dialect) {
320
411
  },
321
412
  ],
322
413
  requestBody: {
323
- content: { "application/json": { schema: bodySchema(entity, "create") } },
414
+ content: {
415
+ [ctx.shape.formCreate === true ? "application/x-www-form-urlencoded" : "application/json"]: {
416
+ schema: bodySchema(entity, "create"),
417
+ },
418
+ },
324
419
  required: true,
325
420
  },
326
421
  responses: {
327
- "201": jsonResponse(`Created ${entity.name}`, itemSchema(entity)),
328
- ...errorResponses([400, 401, 403, 404, 409, 415]),
422
+ [ok(ctx, 201)]: jsonResponse(`Created ${entity.name}`, itemSchemaRef(ctx, entity)),
423
+ ...errorResponses(ctx, [400, 401, 403, 404, 409, 415]),
329
424
  },
330
425
  summary: `Create ${entity.name}`,
331
426
  tags: [title],
@@ -337,47 +432,50 @@ function buildEntityPaths(entity, dialect) {
337
432
  * carries a derived value — the parent's routes as well. Declaring it is what makes
338
433
  * the cross-entity consistency testable rather than assumed.
339
434
  */
340
- "x-invalidate": [listRoute, ...parentReadRoutes(entity)],
435
+ "x-invalidate": [listRoute, ...parentReadRoutes(ctx, entity)],
341
436
  "x-tenant": tenantParam(entity),
342
437
  },
343
438
  };
344
439
  const item = {
345
440
  delete: {
346
441
  operationId: `${entity.name}.delete`,
347
- parameters: pathParams(entity, true),
442
+ parameters: pathParams(ctx, entity, true),
348
443
  responses: {
349
- "200": jsonResponse(`Deleted ${entity.name}`, itemSchema(entity)),
350
- ...errorResponses([400, 401, 403, 404]),
444
+ [ok(ctx, 200)]: jsonResponse(`Deleted ${entity.name}`, itemSchemaRef(ctx, entity)),
445
+ ...errorResponses(ctx, [400, 401, 403, 404]),
351
446
  },
352
447
  summary: `Delete ${entity.name}`,
353
448
  tags: [title],
354
449
  "x-entity": { action: "delete", identity: entity.identity, name: entity.name },
355
- "x-invalidate": surface,
450
+ /* A delete changes the parent's derived count just as a create does. */
451
+ "x-invalidate": [...surface, ...parentReadRoutes(ctx, entity)],
356
452
  "x-tenant": tenantParam(entity),
357
453
  ...(entity.softDeleteField === undefined ? {} : { "x-soft-delete": entity.softDeleteField }),
358
454
  },
359
455
  get: {
360
456
  operationId: `${entity.name}.get`,
361
- parameters: pathParams(entity, true),
457
+ parameters: pathParams(ctx, entity, true),
362
458
  responses: {
363
- "200": jsonResponse(entity.name, itemSchema(entity)),
364
- ...errorResponses([400, 401, 403, 404]),
459
+ [ok(ctx, 200)]: jsonResponse(entity.name, itemSchemaRef(ctx, entity)),
460
+ ...errorResponses(ctx, [400, 401, 403, 404]),
365
461
  },
366
462
  summary: `Get ${entity.name}`,
367
463
  tags: [title],
368
464
  "x-entity": { action: "read", identity: entity.identity, name: entity.name },
369
465
  "x-tenant": tenantParam(entity),
370
466
  },
371
- patch: {
467
+ [ctx.shape.updateMethod === "PUT" ? "put" : "patch"]: {
372
468
  operationId: `${entity.name}.update`,
373
- parameters: pathParams(entity, true),
469
+ parameters: pathParams(ctx, entity, true),
374
470
  requestBody: {
375
- content: { "application/json": { schema: bodySchema(entity, "update") } },
471
+ content: {
472
+ "application/json": { schema: bodySchema(entity, "update", ctx.shape.updateMethod === "PUT") },
473
+ },
376
474
  required: true,
377
475
  },
378
476
  responses: {
379
- "200": jsonResponse(`Updated ${entity.name}`, itemSchema(entity)),
380
- ...errorResponses([400, 401, 403, 404, 409, 415]),
477
+ [ok(ctx, 200)]: jsonResponse(`Updated ${entity.name}`, itemSchemaRef(ctx, entity)),
478
+ ...errorResponses(ctx, [400, 401, 403, 404, 409, 415]),
381
479
  },
382
480
  summary: `Update ${entity.name}`,
383
481
  tags: [title],
@@ -393,14 +491,16 @@ function buildEntityPaths(entity, dialect) {
393
491
  };
394
492
  return { [entity.collectionPath]: collection, [entity.itemPath]: item };
395
493
  }
396
- export function buildSpec(dialect = POSTGREST) {
494
+ export function buildSpec(dialect = POSTGREST, ctx = DEFAULT_CONTEXT) {
495
+ const TABLE = entityNamed(ctx, "table");
496
+ const JOB = entityNamed(ctx, "job");
397
497
  const paths = {};
398
- for (const entity of ENTITIES)
399
- Object.assign(paths, buildEntityPaths(entity, dialect));
498
+ for (const entity of ctx.entities)
499
+ Object.assign(paths, buildEntityPaths(ctx, entity, dialect));
400
500
  paths[`${TABLE.itemPath}/invites`] = {
401
501
  post: {
402
502
  operationId: "table.invite",
403
- parameters: pathParams(TABLE, true),
503
+ parameters: pathParams(ctx, TABLE, true),
404
504
  requestBody: {
405
505
  content: {
406
506
  "application/json": {
@@ -415,13 +515,13 @@ export function buildSpec(dialect = POSTGREST) {
415
515
  required: true,
416
516
  },
417
517
  responses: {
418
- "201": jsonResponse("Invite created", {
518
+ [ok(ctx, 201)]: jsonResponse("Invite created", {
419
519
  additionalProperties: false,
420
520
  properties: { grant_id: { type: "string" }, token: { type: "string" } },
421
521
  required: ["grant_id", "token"],
422
522
  type: "object",
423
523
  }),
424
- ...errorResponses([400, 401, 403, 404, 415]),
524
+ ...errorResponses(ctx, [400, 401, 403, 404, 415]),
425
525
  },
426
526
  summary: "Invite another principal to read this table",
427
527
  tags: ["Table"],
@@ -442,13 +542,13 @@ export function buildSpec(dialect = POSTGREST) {
442
542
  operationId: "invite.accept",
443
543
  parameters: [{ in: "path", name: "token", required: true, schema: { type: "string" } }],
444
544
  responses: {
445
- "200": jsonResponse("Invite accepted", {
545
+ [ok(ctx, 200)]: jsonResponse("Invite accepted", {
446
546
  additionalProperties: false,
447
547
  properties: { accepted: { type: "boolean" } },
448
548
  required: ["accepted"],
449
549
  type: "object",
450
550
  }),
451
- ...errorResponses([400, 401, 404]),
551
+ ...errorResponses(ctx, [400, 401, 404]),
452
552
  },
453
553
  summary: "Accept an invite",
454
554
  tags: ["Table"],
@@ -459,17 +559,17 @@ export function buildSpec(dialect = POSTGREST) {
459
559
  delete: {
460
560
  operationId: "table.revoke",
461
561
  parameters: [
462
- ...pathParams(TABLE, true),
562
+ ...pathParams(ctx, TABLE, true),
463
563
  { in: "path", name: "grant_id", required: true, schema: { type: "string" } },
464
564
  ],
465
565
  responses: {
466
- "200": jsonResponse("Grant revoked", {
566
+ [ok(ctx, 200)]: jsonResponse("Grant revoked", {
467
567
  additionalProperties: false,
468
568
  properties: { revoked: { type: "boolean" } },
469
569
  required: ["revoked"],
470
570
  type: "object",
471
571
  }),
472
- ...errorResponses([400, 401, 403, 404]),
572
+ ...errorResponses(ctx, [400, 401, 403, 404]),
473
573
  },
474
574
  summary: "Revoke a grant",
475
575
  tags: ["Table"],
@@ -482,7 +582,7 @@ export function buildSpec(dialect = POSTGREST) {
482
582
  paths[`${JOB.collectionPath}/start`] = {
483
583
  post: {
484
584
  operationId: "job.start",
485
- parameters: pathParams(JOB, false),
585
+ parameters: pathParams(ctx, JOB, false),
486
586
  requestBody: {
487
587
  content: {
488
588
  "application/json": {
@@ -497,13 +597,13 @@ export function buildSpec(dialect = POSTGREST) {
497
597
  required: true,
498
598
  },
499
599
  responses: {
500
- "202": jsonResponse("Job accepted", {
600
+ [ok(ctx, 202)]: jsonResponse("Job accepted", {
501
601
  additionalProperties: false,
502
602
  properties: { accepted: { type: "boolean" }, job_id: { type: "string" } },
503
603
  required: ["job_id", "accepted"],
504
604
  type: "object",
505
605
  }),
506
- ...errorResponses([400, 401, 403, 404, 415]),
606
+ ...errorResponses(ctx, [400, 401, 403, 404, 415]),
507
607
  },
508
608
  summary: "Start a job",
509
609
  tags: ["Job"],
@@ -520,6 +620,69 @@ export function buildSpec(dialect = POSTGREST) {
520
620
  "x-tenant": tenantParam(JOB),
521
621
  },
522
622
  };
623
+ /* A write whose effect arrives elsewhere, later. x-wait tells oat where to look and how long. */
624
+ paths[`${JOB.collectionPath.replace("/jobs", "")}/notifications`] = {
625
+ post: {
626
+ operationId: "job.notify",
627
+ parameters: pathParams(ctx, JOB, false),
628
+ requestBody: {
629
+ content: {
630
+ "application/json": {
631
+ schema: {
632
+ additionalProperties: false,
633
+ properties: { message: { maxLength: 200, minLength: 1, type: "string" } },
634
+ required: ["message"],
635
+ type: "object",
636
+ },
637
+ },
638
+ },
639
+ required: true,
640
+ },
641
+ responses: {
642
+ [ok(ctx, 202)]: jsonResponse("Notification queued", {
643
+ additionalProperties: false,
644
+ properties: { accepted: { type: "boolean" }, notification_id: { type: "string" } },
645
+ required: ["accepted", "notification_id"],
646
+ type: "object",
647
+ }),
648
+ ...errorResponses(ctx, [400, 401, 403, 404, 415]),
649
+ },
650
+ summary: "Queue a notification for delivery to the project inbox",
651
+ tags: ["Job"],
652
+ "x-entity": { action: "action", identity: "id", name: "job" },
653
+ "x-tenant": tenantParam(JOB),
654
+ "x-wait": { operationId: "job.inbox", timeoutMs: 2000, until: "$.messages[0]" },
655
+ },
656
+ };
657
+ paths[`${JOB.collectionPath.replace("/jobs", "")}/inbox`] = {
658
+ get: {
659
+ operationId: "job.inbox",
660
+ parameters: pathParams(ctx, JOB, false),
661
+ responses: {
662
+ [ok(ctx, 200)]: jsonResponse("Delivered notifications", {
663
+ additionalProperties: false,
664
+ properties: {
665
+ messages: {
666
+ items: {
667
+ additionalProperties: false,
668
+ properties: { id: { type: "string" }, message: { type: "string" } },
669
+ required: ["id", "message"],
670
+ type: "object",
671
+ },
672
+ type: "array",
673
+ },
674
+ },
675
+ required: ["messages"],
676
+ type: "object",
677
+ }),
678
+ ...errorResponses(ctx, [401, 403, 404]),
679
+ },
680
+ summary: "List delivered notifications",
681
+ tags: ["Job"],
682
+ "x-entity": { action: "action", identity: "id", name: "job" },
683
+ "x-tenant": tenantParam(JOB),
684
+ },
685
+ };
523
686
  paths["/v1/auth/token"] = {
524
687
  post: {
525
688
  operationId: "auth.token",
@@ -537,7 +700,7 @@ export function buildSpec(dialect = POSTGREST) {
537
700
  required: true,
538
701
  },
539
702
  responses: {
540
- "200": jsonResponse("Access token", {
703
+ [ok(ctx, 200)]: jsonResponse("Access token", {
541
704
  additionalProperties: false,
542
705
  properties: {
543
706
  access_token: { type: "string" },
@@ -547,7 +710,7 @@ export function buildSpec(dialect = POSTGREST) {
547
710
  required: ["access_token", "expires_in", "project_id"],
548
711
  type: "object",
549
712
  }),
550
- ...errorResponses([400, 401, 415]),
713
+ ...errorResponses(ctx, [400, 401, 415]),
551
714
  },
552
715
  security: [],
553
716
  summary: "Exchange an API key for an access token",
@@ -557,14 +720,42 @@ export function buildSpec(dialect = POSTGREST) {
557
720
  const schemas = {};
558
721
  for (const [status, key] of ERRORS)
559
722
  schemas[`Err${status}`] = errorSchema(status, key);
560
- return {
561
- components: {
562
- schemas,
563
- securitySchemes: { bearer: { bearerFormat: "JWT", scheme: "bearer", type: "http" } },
723
+ schemas.Error = {
724
+ additionalProperties: false,
725
+ properties: {
726
+ error_key: { type: "string" },
727
+ message: { type: "string" },
728
+ status: { type: "integer" },
729
+ success: { const: false },
730
+ },
731
+ required: ["error_key", "message", "status", "success"],
732
+ type: "object",
733
+ };
734
+ schemas.Point = {
735
+ additionalProperties: false,
736
+ properties: { x: { type: "integer" }, y: { type: "integer" } },
737
+ required: ["x", "y"],
738
+ type: "object",
739
+ };
740
+ /* Recursive: a node's children are nodes. Real documents describe trees, comment threads and
741
+ * org charts this way, and nothing about it is unusual — except to a tool that inlines refs. */
742
+ schemas.TreeNode = {
743
+ properties: {
744
+ children: { items: { $ref: "#/components/schemas/TreeNode" }, type: "array" },
745
+ name: { type: "string" },
564
746
  },
565
- info: { title: "oat reference backend", version: "1.0.0" },
566
- openapi: "3.1.0",
567
- paths,
747
+ required: ["name"],
748
+ type: "object",
749
+ };
750
+ if (ctx.shape.specLayout === true) {
751
+ for (const entity of ctx.entities)
752
+ schemas[schemaName(entity)] = itemSchema(entity);
753
+ }
754
+ const components = {
755
+ schemas,
756
+ securitySchemes: { bearer: { bearerFormat: "JWT", scheme: "bearer", type: "http" } },
757
+ };
758
+ const rest = {
568
759
  security: [{ bearer: [] }],
569
760
  "x-auth-flows": {
570
761
  default: {
@@ -574,10 +765,39 @@ export function buildSpec(dialect = POSTGREST) {
574
765
  },
575
766
  },
576
767
  };
768
+ const head = { info: { title: "oat reference backend", version: "1.0.0" }, openapi: "3.1.0" };
769
+ /* Key order is the layout: a document is free to define its components before its paths, and
770
+ * to declare path parameters once on the path item rather than on every operation. */
771
+ if (ctx.shape.specLayout === true) {
772
+ return { ...head, components, paths: hoistPathParameters(paths), ...rest };
773
+ }
774
+ return { ...head, paths, components, ...rest };
775
+ }
776
+ /** Moves each path item's `in: path` parameters from its operations onto the path item itself. */
777
+ function hoistPathParameters(paths) {
778
+ const out = {};
779
+ for (const [route, item] of Object.entries(paths)) {
780
+ const operations = Object.entries(item);
781
+ const shared = new Map();
782
+ const hoisted = {};
783
+ for (const [method, operation] of operations) {
784
+ const parameters = (operation.parameters ?? []);
785
+ for (const parameter of parameters) {
786
+ if (parameter.in === "path")
787
+ shared.set(String(parameter.name), parameter);
788
+ }
789
+ hoisted[method] = {
790
+ ...operation,
791
+ parameters: parameters.filter((parameter) => parameter.in !== "path"),
792
+ };
793
+ }
794
+ out[route] = shared.size === 0 ? item : { parameters: [...shared.values()], ...hoisted };
795
+ }
796
+ return out;
577
797
  }
578
798
  /** Variant with every oat meta tag removed — exercises the heuristic fallbacks. */
579
- export function buildUntaggedSpec(dialect = POSTGREST) {
580
- const spec = buildSpec(dialect);
799
+ export function buildUntaggedSpec(dialect = POSTGREST, ctx = DEFAULT_CONTEXT) {
800
+ const spec = buildSpec(dialect, ctx);
581
801
  const strip = (node) => {
582
802
  if (Array.isArray(node))
583
803
  return node.map(strip);