local-serverless-stack 0.6.0 → 0.9.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 (257) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +261 -583
  3. package/bin/cli.js +31 -3
  4. package/dist/client/types.d.ts +4 -0
  5. package/dist/client/types.d.ts.map +1 -1
  6. package/dist/server/engine/aoss-sidecar.d.ts +13 -0
  7. package/dist/server/engine/aoss-sidecar.d.ts.map +1 -0
  8. package/dist/server/engine/aoss-sidecar.js +93 -0
  9. package/dist/server/engine/aoss-sidecar.js.map +1 -0
  10. package/dist/server/engine/backends/localstack-backend.d.ts +25 -0
  11. package/dist/server/engine/backends/localstack-backend.d.ts.map +1 -0
  12. package/dist/server/engine/backends/localstack-backend.js +199 -0
  13. package/dist/server/engine/backends/localstack-backend.js.map +1 -0
  14. package/dist/server/engine/backends/self-backend.d.ts +32 -0
  15. package/dist/server/engine/backends/self-backend.d.ts.map +1 -0
  16. package/dist/server/engine/backends/self-backend.js +207 -0
  17. package/dist/server/engine/backends/self-backend.js.map +1 -0
  18. package/dist/server/engine/bus.d.ts +10 -0
  19. package/dist/server/engine/bus.d.ts.map +1 -0
  20. package/dist/server/engine/bus.js +24 -0
  21. package/dist/server/engine/bus.js.map +1 -0
  22. package/dist/server/engine/dispatch/dispatcher.d.ts +46 -0
  23. package/dist/server/engine/dispatch/dispatcher.d.ts.map +1 -0
  24. package/dist/server/engine/dispatch/dispatcher.js +312 -0
  25. package/dist/server/engine/dispatch/dispatcher.js.map +1 -0
  26. package/dist/server/engine/dispatch/scheduler.d.ts +47 -0
  27. package/dist/server/engine/dispatch/scheduler.d.ts.map +1 -0
  28. package/dist/server/engine/dispatch/scheduler.js +328 -0
  29. package/dist/server/engine/dispatch/scheduler.js.map +1 -0
  30. package/dist/server/engine/dispatch/sqs-poller.d.ts +25 -0
  31. package/dist/server/engine/dispatch/sqs-poller.d.ts.map +1 -0
  32. package/dist/server/engine/dispatch/sqs-poller.js +242 -0
  33. package/dist/server/engine/dispatch/sqs-poller.js.map +1 -0
  34. package/dist/server/engine/dispatch/stream-tailer.d.ts +22 -0
  35. package/dist/server/engine/dispatch/stream-tailer.d.ts.map +1 -0
  36. package/dist/server/engine/dispatch/stream-tailer.js +205 -0
  37. package/dist/server/engine/dispatch/stream-tailer.js.map +1 -0
  38. package/dist/server/engine/emulators/dynamodb/expressions/decimal.d.ts +5 -0
  39. package/dist/server/engine/emulators/dynamodb/expressions/decimal.d.ts.map +1 -0
  40. package/dist/server/engine/emulators/dynamodb/expressions/decimal.js +91 -0
  41. package/dist/server/engine/emulators/dynamodb/expressions/decimal.js.map +1 -0
  42. package/dist/server/engine/emulators/dynamodb/expressions/evaluate.d.ts +24 -0
  43. package/dist/server/engine/emulators/dynamodb/expressions/evaluate.d.ts.map +1 -0
  44. package/dist/server/engine/emulators/dynamodb/expressions/evaluate.js +183 -0
  45. package/dist/server/engine/emulators/dynamodb/expressions/evaluate.js.map +1 -0
  46. package/dist/server/engine/emulators/dynamodb/expressions/expression-types.d.ts +29 -0
  47. package/dist/server/engine/emulators/dynamodb/expressions/expression-types.d.ts.map +1 -0
  48. package/dist/server/engine/emulators/dynamodb/expressions/expression-types.js +12 -0
  49. package/dist/server/engine/emulators/dynamodb/expressions/expression-types.js.map +1 -0
  50. package/dist/server/engine/emulators/dynamodb/expressions/index.d.ts +13 -0
  51. package/dist/server/engine/emulators/dynamodb/expressions/index.d.ts.map +1 -0
  52. package/dist/server/engine/emulators/dynamodb/expressions/index.js +244 -0
  53. package/dist/server/engine/emulators/dynamodb/expressions/index.js.map +1 -0
  54. package/dist/server/engine/emulators/dynamodb/expressions/lexer.d.ts +11 -0
  55. package/dist/server/engine/emulators/dynamodb/expressions/lexer.d.ts.map +1 -0
  56. package/dist/server/engine/emulators/dynamodb/expressions/lexer.js +109 -0
  57. package/dist/server/engine/emulators/dynamodb/expressions/lexer.js.map +1 -0
  58. package/dist/server/engine/emulators/dynamodb/expressions/parser.d.ts +23 -0
  59. package/dist/server/engine/emulators/dynamodb/expressions/parser.d.ts.map +1 -0
  60. package/dist/server/engine/emulators/dynamodb/expressions/parser.js +541 -0
  61. package/dist/server/engine/emulators/dynamodb/expressions/parser.js.map +1 -0
  62. package/dist/server/engine/emulators/dynamodb/index.d.ts +46 -0
  63. package/dist/server/engine/emulators/dynamodb/index.d.ts.map +1 -0
  64. package/dist/server/engine/emulators/dynamodb/index.js +572 -0
  65. package/dist/server/engine/emulators/dynamodb/index.js.map +1 -0
  66. package/dist/server/engine/emulators/dynamodb/query.d.ts +12 -0
  67. package/dist/server/engine/emulators/dynamodb/query.d.ts.map +1 -0
  68. package/dist/server/engine/emulators/dynamodb/query.js +237 -0
  69. package/dist/server/engine/emulators/dynamodb/query.js.map +1 -0
  70. package/dist/server/engine/emulators/dynamodb/schema.d.ts +58 -0
  71. package/dist/server/engine/emulators/dynamodb/schema.d.ts.map +1 -0
  72. package/dist/server/engine/emulators/dynamodb/schema.js +284 -0
  73. package/dist/server/engine/emulators/dynamodb/schema.js.map +1 -0
  74. package/dist/server/engine/emulators/dynamodb/streams.d.ts +28 -0
  75. package/dist/server/engine/emulators/dynamodb/streams.d.ts.map +1 -0
  76. package/dist/server/engine/emulators/dynamodb/streams.js +100 -0
  77. package/dist/server/engine/emulators/dynamodb/streams.js.map +1 -0
  78. package/dist/server/engine/emulators/events/index.d.ts +53 -0
  79. package/dist/server/engine/emulators/events/index.d.ts.map +1 -0
  80. package/dist/server/engine/emulators/events/index.js +417 -0
  81. package/dist/server/engine/emulators/events/index.js.map +1 -0
  82. package/dist/server/engine/emulators/events/pattern.d.ts +3 -0
  83. package/dist/server/engine/emulators/events/pattern.d.ts.map +1 -0
  84. package/dist/server/engine/emulators/events/pattern.js +125 -0
  85. package/dist/server/engine/emulators/events/pattern.js.map +1 -0
  86. package/dist/server/engine/emulators/lambda-ctl/index.d.ts +55 -0
  87. package/dist/server/engine/emulators/lambda-ctl/index.d.ts.map +1 -0
  88. package/dist/server/engine/emulators/lambda-ctl/index.js +461 -0
  89. package/dist/server/engine/emulators/lambda-ctl/index.js.map +1 -0
  90. package/dist/server/engine/emulators/opensearch/index.d.ts +67 -0
  91. package/dist/server/engine/emulators/opensearch/index.d.ts.map +1 -0
  92. package/dist/server/engine/emulators/opensearch/index.js +811 -0
  93. package/dist/server/engine/emulators/opensearch/index.js.map +1 -0
  94. package/dist/server/engine/emulators/opensearch/search.d.ts +17 -0
  95. package/dist/server/engine/emulators/opensearch/search.d.ts.map +1 -0
  96. package/dist/server/engine/emulators/opensearch/search.js +434 -0
  97. package/dist/server/engine/emulators/opensearch/search.js.map +1 -0
  98. package/dist/server/engine/emulators/s3/index.d.ts +45 -0
  99. package/dist/server/engine/emulators/s3/index.d.ts.map +1 -0
  100. package/dist/server/engine/emulators/s3/index.js +591 -0
  101. package/dist/server/engine/emulators/s3/index.js.map +1 -0
  102. package/dist/server/engine/emulators/sns/index.d.ts +22 -0
  103. package/dist/server/engine/emulators/sns/index.d.ts.map +1 -0
  104. package/dist/server/engine/emulators/sns/index.js +135 -0
  105. package/dist/server/engine/emulators/sns/index.js.map +1 -0
  106. package/dist/server/engine/emulators/sqs/index.d.ts +66 -0
  107. package/dist/server/engine/emulators/sqs/index.d.ts.map +1 -0
  108. package/dist/server/engine/emulators/sqs/index.js +552 -0
  109. package/dist/server/engine/emulators/sqs/index.js.map +1 -0
  110. package/dist/server/engine/emulators/sqs/md5.d.ts +8 -0
  111. package/dist/server/engine/emulators/sqs/md5.d.ts.map +1 -0
  112. package/dist/server/engine/emulators/sqs/md5.js +36 -0
  113. package/dist/server/engine/emulators/sqs/md5.js.map +1 -0
  114. package/dist/server/engine/emulators/sqs/queue.d.ts +105 -0
  115. package/dist/server/engine/emulators/sqs/queue.d.ts.map +1 -0
  116. package/dist/server/engine/emulators/sqs/queue.js +371 -0
  117. package/dist/server/engine/emulators/sqs/queue.js.map +1 -0
  118. package/dist/server/engine/emulators/sts.d.ts +8 -0
  119. package/dist/server/engine/emulators/sts.d.ts.map +1 -0
  120. package/dist/server/engine/emulators/sts.js +23 -0
  121. package/dist/server/engine/emulators/sts.js.map +1 -0
  122. package/dist/server/engine/engine-backend.d.ts +17 -0
  123. package/dist/server/engine/engine-backend.d.ts.map +1 -0
  124. package/dist/server/engine/engine-backend.js +7 -0
  125. package/dist/server/engine/engine-backend.js.map +1 -0
  126. package/dist/server/engine/engine-manager.d.ts +22 -0
  127. package/dist/server/engine/engine-manager.d.ts.map +1 -0
  128. package/dist/server/engine/engine-manager.js +74 -0
  129. package/dist/server/engine/engine-manager.js.map +1 -0
  130. package/dist/server/engine/http/aws-chunked.d.ts +3 -0
  131. package/dist/server/engine/http/aws-chunked.d.ts.map +1 -0
  132. package/dist/server/engine/http/aws-chunked.js +50 -0
  133. package/dist/server/engine/http/aws-chunked.js.map +1 -0
  134. package/dist/server/engine/http/errors.d.ts +20 -0
  135. package/dist/server/engine/http/errors.d.ts.map +1 -0
  136. package/dist/server/engine/http/errors.js +43 -0
  137. package/dist/server/engine/http/errors.js.map +1 -0
  138. package/dist/server/engine/http/protocols/aws-json.d.ts +8 -0
  139. package/dist/server/engine/http/protocols/aws-json.d.ts.map +1 -0
  140. package/dist/server/engine/http/protocols/aws-json.js +42 -0
  141. package/dist/server/engine/http/protocols/aws-json.js.map +1 -0
  142. package/dist/server/engine/http/protocols/query-xml.d.ts +7 -0
  143. package/dist/server/engine/http/protocols/query-xml.d.ts.map +1 -0
  144. package/dist/server/engine/http/protocols/query-xml.js +35 -0
  145. package/dist/server/engine/http/protocols/query-xml.js.map +1 -0
  146. package/dist/server/engine/http/router.d.ts +8 -0
  147. package/dist/server/engine/http/router.d.ts.map +1 -0
  148. package/dist/server/engine/http/router.js +402 -0
  149. package/dist/server/engine/http/router.js.map +1 -0
  150. package/dist/server/engine/http/sigv4.d.ts +9 -0
  151. package/dist/server/engine/http/sigv4.d.ts.map +1 -0
  152. package/dist/server/engine/http/sigv4.js +33 -0
  153. package/dist/server/engine/http/sigv4.js.map +1 -0
  154. package/dist/server/engine/http/xml.d.ts +16 -0
  155. package/dist/server/engine/http/xml.d.ts.map +1 -0
  156. package/dist/server/engine/http/xml.js +92 -0
  157. package/dist/server/engine/http/xml.js.map +1 -0
  158. package/dist/server/engine/store/atomic.d.ts +4 -0
  159. package/dist/server/engine/store/atomic.d.ts.map +1 -0
  160. package/dist/server/engine/store/atomic.js +35 -0
  161. package/dist/server/engine/store/atomic.js.map +1 -0
  162. package/dist/server/engine/store/engine-store.d.ts +9 -0
  163. package/dist/server/engine/store/engine-store.d.ts.map +1 -0
  164. package/dist/server/engine/store/engine-store.js +218 -0
  165. package/dist/server/engine/store/engine-store.js.map +1 -0
  166. package/dist/server/engine/store/store-types.d.ts +37 -0
  167. package/dist/server/engine/store/store-types.d.ts.map +1 -0
  168. package/dist/server/engine/store/store-types.js +17 -0
  169. package/dist/server/engine/store/store-types.js.map +1 -0
  170. package/dist/server/engine/store/wal.d.ts +49 -0
  171. package/dist/server/engine/store/wal.d.ts.map +1 -0
  172. package/dist/server/engine/store/wal.js +328 -0
  173. package/dist/server/engine/store/wal.js.map +1 -0
  174. package/dist/server/engine/types.d.ts +137 -0
  175. package/dist/server/engine/types.d.ts.map +1 -0
  176. package/dist/server/engine/types.js +8 -0
  177. package/dist/server/engine/types.js.map +1 -0
  178. package/dist/server/index.js +43 -9
  179. package/dist/server/index.js.map +1 -1
  180. package/dist/server/routes/config.d.ts.map +1 -1
  181. package/dist/server/routes/config.js +16 -0
  182. package/dist/server/routes/config.js.map +1 -1
  183. package/dist/server/routes/opensearch.d.ts +3 -0
  184. package/dist/server/routes/opensearch.d.ts.map +1 -0
  185. package/dist/server/routes/opensearch.js +69 -0
  186. package/dist/server/routes/opensearch.js.map +1 -0
  187. package/dist/server/routes/resources.d.ts.map +1 -1
  188. package/dist/server/routes/resources.js +4 -1
  189. package/dist/server/routes/resources.js.map +1 -1
  190. package/dist/server/routes/services.d.ts.map +1 -1
  191. package/dist/server/routes/services.js +3 -0
  192. package/dist/server/routes/services.js.map +1 -1
  193. package/dist/server/services/cache-manager.d.ts +2 -1
  194. package/dist/server/services/cache-manager.d.ts.map +1 -1
  195. package/dist/server/services/cache-manager.js +22 -2
  196. package/dist/server/services/cache-manager.js.map +1 -1
  197. package/dist/server/services/cloudformation-parser.d.ts +35 -2
  198. package/dist/server/services/cloudformation-parser.d.ts.map +1 -1
  199. package/dist/server/services/cloudformation-parser.js +88 -3
  200. package/dist/server/services/cloudformation-parser.js.map +1 -1
  201. package/dist/server/services/config-manager.d.ts +82 -0
  202. package/dist/server/services/config-manager.d.ts.map +1 -1
  203. package/dist/server/services/config-manager.js +151 -7
  204. package/dist/server/services/config-manager.js.map +1 -1
  205. package/dist/server/services/gateway-manager.d.ts +2 -0
  206. package/dist/server/services/gateway-manager.d.ts.map +1 -1
  207. package/dist/server/services/gateway-manager.js +44 -6
  208. package/dist/server/services/gateway-manager.js.map +1 -1
  209. package/dist/server/services/localstack-manager.d.ts +0 -7
  210. package/dist/server/services/localstack-manager.d.ts.map +1 -1
  211. package/dist/server/services/localstack-manager.js +11 -173
  212. package/dist/server/services/localstack-manager.js.map +1 -1
  213. package/dist/server/services/opensearch-explorer.d.ts +37 -0
  214. package/dist/server/services/opensearch-explorer.d.ts.map +1 -0
  215. package/dist/server/services/opensearch-explorer.js +124 -0
  216. package/dist/server/services/opensearch-explorer.js.map +1 -0
  217. package/dist/server/services/resource-provisioner.d.ts +13 -0
  218. package/dist/server/services/resource-provisioner.d.ts.map +1 -1
  219. package/dist/server/services/resource-provisioner.js +257 -11
  220. package/dist/server/services/resource-provisioner.js.map +1 -1
  221. package/dist/server/services/service-registrar.d.ts +1 -0
  222. package/dist/server/services/service-registrar.d.ts.map +1 -1
  223. package/dist/server/services/service-registrar.js +19 -4
  224. package/dist/server/services/service-registrar.js.map +1 -1
  225. package/dist/ui/assets/ApisPage-C0xaNFQW.js +1 -0
  226. package/dist/ui/assets/BucketDetailPage-Bnny8S04.js +1 -0
  227. package/dist/ui/assets/BucketsPage-Dd3Y4-gU.js +1 -0
  228. package/dist/ui/assets/{DynamoPage-B1__Rjr2.js → DynamoPage-JzSF-4VI.js} +1 -1
  229. package/dist/ui/assets/DynamoTablePage-uM0DXGCo.js +3 -0
  230. package/dist/ui/assets/LambdaDetailPage-CoR9ZXdb.js +4 -0
  231. package/dist/ui/assets/LambdasPage-CNMX_O5P.js +1 -0
  232. package/dist/ui/assets/OpenSearchCollectionPage-D5FaSCF1.js +1 -0
  233. package/dist/ui/assets/OpenSearchPage-FEzLxaej.js +1 -0
  234. package/dist/ui/assets/OverviewPage-0xvjT7Q0.js +1 -0
  235. package/dist/ui/assets/OverviewPage-qEl0a-0B.css +1 -0
  236. package/dist/ui/assets/QueueDetailPage-B2GaNLLe.js +1 -0
  237. package/dist/ui/assets/QueuesPage-Ch5kGwrp.js +1 -0
  238. package/dist/ui/assets/ServiceDetailPage-DKiz21oo.js +2 -0
  239. package/dist/ui/assets/ServicesPage-Cmi3UIfM.js +2 -0
  240. package/dist/ui/assets/index-DH7RTkRh.css +1 -0
  241. package/dist/ui/assets/index-MF9fVrsr.js +37 -0
  242. package/dist/ui/index.html +2 -2
  243. package/package.json +3 -1
  244. package/dist/ui/assets/ApisPage-DdgKjEdS.js +0 -1
  245. package/dist/ui/assets/BucketDetailPage-DZQGxm0y.js +0 -1
  246. package/dist/ui/assets/BucketsPage-xYXzxCvd.js +0 -1
  247. package/dist/ui/assets/DynamoTablePage-BCoxPdXW.js +0 -3
  248. package/dist/ui/assets/LambdaDetailPage-B85CA8tb.js +0 -4
  249. package/dist/ui/assets/LambdasPage-_TtxQMT_.js +0 -1
  250. package/dist/ui/assets/OverviewPage-6jDXV1jr.css +0 -1
  251. package/dist/ui/assets/OverviewPage-CqTKTY64.js +0 -1
  252. package/dist/ui/assets/QueueDetailPage-BOr_sImA.js +0 -1
  253. package/dist/ui/assets/QueuesPage-DpByfcM5.js +0 -1
  254. package/dist/ui/assets/ServiceDetailPage-DsWgV-ad.js +0 -2
  255. package/dist/ui/assets/ServicesPage-CGlpsj98.js +0 -2
  256. package/dist/ui/assets/index-BWlB542w.js +0 -36
  257. package/dist/ui/assets/index-EYnsoJOq.css +0 -1
@@ -0,0 +1,811 @@
1
+ // OpenSearch Serverless emulator. One RestEmulator covers both planes, split
2
+ // by the request shape the way real AOSS splits them by host:
3
+ //
4
+ // Control plane — AWS JSON 1.0 with X-Amz-Target "OpenSearchServerless.<Op>"
5
+ // (CreateCollection, BatchGetCollection, ListCollections, DeleteCollection).
6
+ // Errors are AwsError, serialized by the router with the JSON shape.
7
+ //
8
+ // Data plane — the OpenSearch REST API served under the collection endpoint
9
+ // the control plane hands out: <engine>/_aoss/<collection>/... (index and
10
+ // document CRUD, _bulk, _search, _count, _mapping). Errors are
11
+ // OpenSearch-shaped JSON ({"error": {...}, "status": N}), which is what
12
+ // OpenSearch clients expect to parse.
13
+ //
14
+ // Collections and index metadata are catalog entries; documents live in one
15
+ // ItemTable per index (lazy hydration / idle unload for free). No SigV4
16
+ // verification and no policy enforcement, matching the engine-wide bar.
17
+ import { createHash, randomUUID } from 'crypto';
18
+ import { AwsError, notImplemented } from '../../http/errors.js';
19
+ import { jsonSuccessResponse } from '../../http/protocols/aws-json.js';
20
+ import { computeAggregations, filterSource, matchesQuery, normalizeSort, sortHits, } from './search.js';
21
+ const TARGET_PREFIX = 'OpenSearchServerless';
22
+ // AOSS collection naming rule: 3-32 chars, lowercase letter first, then
23
+ // lowercase letters/digits/hyphens. Also keeps store paths safe.
24
+ const COLLECTION_NAME = /^[a-z][a-z0-9-]{2,31}$/;
25
+ // Index names: conservative subset of the OpenSearch rules that is also safe
26
+ // as a store path segment.
27
+ const INDEX_NAME = /^[a-z0-9][a-z0-9._-]{0,254}$/;
28
+ const COLLECTION_TYPES = ['SEARCH', 'TIMESERIES', 'VECTORSEARCH'];
29
+ const DEFAULT_SEARCH_SIZE = 10;
30
+ // Data-plane failure carrying the OpenSearch error type; converted to the
31
+ // {"error": {...}, "status": N} JSON shape at the route boundary.
32
+ export class OsError extends Error {
33
+ status;
34
+ type;
35
+ constructor(status, type, reason) {
36
+ super(reason);
37
+ this.name = 'OsError';
38
+ this.status = status;
39
+ this.type = type;
40
+ }
41
+ }
42
+ function osErrorResponse(err) {
43
+ const cause = { type: err.type, reason: err.message };
44
+ return {
45
+ status: err.status,
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ error: { root_cause: [cause], ...cause }, status: err.status }),
48
+ };
49
+ }
50
+ function jsonResponse(status, payload) {
51
+ return { status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) };
52
+ }
53
+ function decodeSegment(segment) {
54
+ try {
55
+ return decodeURIComponent(segment);
56
+ }
57
+ catch {
58
+ return segment;
59
+ }
60
+ }
61
+ function parseBodyJson(req, errorType) {
62
+ if (req.body.length === 0)
63
+ return {};
64
+ try {
65
+ const parsed = JSON.parse(req.body.toString('utf8'));
66
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
67
+ throw new Error('not an object');
68
+ }
69
+ return parsed;
70
+ }
71
+ catch {
72
+ throw new OsError(400, errorType, 'Could not parse the request body as a JSON object');
73
+ }
74
+ }
75
+ // Deep merge for _update: objects merge recursively, everything else (arrays
76
+ // included) replaces — OpenSearch partial-update semantics.
77
+ function mergeDoc(base, patch) {
78
+ const result = { ...base };
79
+ for (const [key, value] of Object.entries(patch)) {
80
+ const current = result[key];
81
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) &&
82
+ current !== null && typeof current === 'object' && !Array.isArray(current)) {
83
+ result[key] = mergeDoc(current, value);
84
+ }
85
+ else {
86
+ result[key] = value;
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ export class OpenSearchEmulator {
92
+ service = 'aoss';
93
+ ctx;
94
+ constructor(ctx) {
95
+ this.ctx = ctx;
96
+ }
97
+ async handle(req) {
98
+ const target = req.headers['x-amz-target'];
99
+ if (target !== undefined && target.startsWith(`${TARGET_PREFIX}.`)) {
100
+ const operation = target.slice(TARGET_PREFIX.length + 1);
101
+ return jsonSuccessResponse(await this.handleControlPlane(operation, req), '1.0');
102
+ }
103
+ return this.handleDataPlane(req);
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Control plane (AWS JSON 1.0)
107
+ // ---------------------------------------------------------------------------
108
+ async handleControlPlane(operation, req) {
109
+ let input = {};
110
+ if (req.body.length > 0) {
111
+ try {
112
+ input = JSON.parse(req.body.toString('utf8'));
113
+ }
114
+ catch {
115
+ throw new AwsError('SerializationException', 'Could not parse the request body as JSON');
116
+ }
117
+ }
118
+ const region = req.region;
119
+ switch (operation) {
120
+ case 'CreateCollection': return this.createCollection(region, input);
121
+ case 'BatchGetCollection': return this.batchGetCollection(region, input);
122
+ case 'ListCollections': return this.listCollections(region);
123
+ case 'DeleteCollection': return this.deleteCollection(region, input);
124
+ default: throw notImplemented('aoss', operation);
125
+ }
126
+ }
127
+ async createCollection(region, input) {
128
+ const name = typeof input.name === 'string' ? input.name : '';
129
+ if (!COLLECTION_NAME.test(name)) {
130
+ throw new AwsError('ValidationException', `Collection name must match ${COLLECTION_NAME} (3-32 chars, lowercase letters, digits and hyphens, starting with a letter).`);
131
+ }
132
+ const type = typeof input.type === 'string' ? input.type : 'TIMESERIES';
133
+ if (!COLLECTION_TYPES.includes(type)) {
134
+ throw new AwsError('ValidationException', `Collection type must be one of ${COLLECTION_TYPES.join(', ')}.`);
135
+ }
136
+ const collections = await this.collections(region);
137
+ if (collections.get(name)) {
138
+ throw new AwsError('ConflictException', `A collection with the name ${name} already exists.`, { status: 409 });
139
+ }
140
+ const record = {
141
+ id: this.collectionId(region, name),
142
+ name,
143
+ type,
144
+ ...(typeof input.description === 'string' ? { description: input.description } : {}),
145
+ createdDate: Date.now(),
146
+ };
147
+ collections.set(name, record);
148
+ return { createCollectionDetail: this.collectionDetail(region, record) };
149
+ }
150
+ async batchGetCollection(region, input) {
151
+ const collections = await this.collections(region);
152
+ const names = Array.isArray(input.names) ? input.names.map(String) : undefined;
153
+ const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined;
154
+ if ((names === undefined) === (ids === undefined)) {
155
+ throw new AwsError('ValidationException', 'Specify exactly one of names or ids.');
156
+ }
157
+ const details = [];
158
+ const errors = [];
159
+ for (const ref of names ?? ids ?? []) {
160
+ const record = names !== undefined
161
+ ? collections.get(ref)
162
+ : collections.values().find(candidate => candidate.id === ref);
163
+ if (record) {
164
+ details.push({
165
+ ...this.collectionDetail(region, record),
166
+ collectionEndpoint: this.collectionEndpoint(record.name),
167
+ dashboardEndpoint: `${this.collectionEndpoint(record.name)}/_dashboards`,
168
+ });
169
+ }
170
+ else {
171
+ errors.push({
172
+ ...(names !== undefined ? { name: ref } : { id: ref }),
173
+ errorCode: 'NOT_FOUND',
174
+ errorMessage: `Collection ${ref} not found.`,
175
+ });
176
+ }
177
+ }
178
+ return { collectionDetails: details, collectionErrorDetails: errors };
179
+ }
180
+ async listCollections(region) {
181
+ const collections = await this.collections(region);
182
+ return {
183
+ collectionSummaries: collections.values().map(record => ({
184
+ arn: this.collectionArn(region, record.id),
185
+ id: record.id,
186
+ name: record.name,
187
+ status: 'ACTIVE',
188
+ })),
189
+ };
190
+ }
191
+ async deleteCollection(region, input) {
192
+ const ref = typeof input.id === 'string' ? input.id : '';
193
+ const collections = await this.collections(region);
194
+ // AOSS deletes by id; the name is accepted too so cleanup flows can skip
195
+ // the BatchGetCollection round trip.
196
+ const record = collections.values().find(candidate => candidate.id === ref) ?? collections.get(ref);
197
+ if (!record) {
198
+ throw new AwsError('ResourceNotFoundException', `Collection with ID ${ref} cannot be found.`, { status: 404 });
199
+ }
200
+ const indices = await this.indices(region);
201
+ for (const key of indices.keys()) {
202
+ const entry = indices.get(key);
203
+ if (entry?.collection === record.name) {
204
+ await this.docsTable(region, record.name, entry.index).destroy();
205
+ indices.delete(key);
206
+ }
207
+ }
208
+ collections.delete(record.name);
209
+ return { deleteCollectionDetail: { id: record.id, name: record.name, status: 'DELETING' } };
210
+ }
211
+ collectionDetail(region, record) {
212
+ return {
213
+ arn: this.collectionArn(region, record.id),
214
+ createdDate: record.createdDate,
215
+ ...(record.description !== undefined ? { description: record.description } : {}),
216
+ id: record.id,
217
+ kmsKeyArn: 'auto',
218
+ lastModifiedDate: record.createdDate,
219
+ name: record.name,
220
+ standbyReplicas: 'DISABLED',
221
+ status: 'ACTIVE',
222
+ type: record.type,
223
+ };
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Data plane (OpenSearch REST under /_aoss/<collection>)
227
+ // ---------------------------------------------------------------------------
228
+ async handleDataPlane(req) {
229
+ const segments = req.rawPath.split('/').filter(part => part.length > 0).map(decodeSegment);
230
+ if (segments[0] !== '_aoss' || segments.length < 2) {
231
+ throw new AwsError('ValidationException', `OpenSearch data-plane requests must target a collection endpoint: ${this.ctx.endpoint()}/_aoss/<collection>/... ` +
232
+ '(the collectionEndpoint attribute LSS provisions). Unknown control-plane operations need an X-Amz-Target header.', { status: 404 });
233
+ }
234
+ try {
235
+ const region = req.region;
236
+ const collectionName = segments[1];
237
+ const collections = await this.collections(region);
238
+ if (!collections.get(collectionName)) {
239
+ throw new OsError(404, 'collection_not_found_exception', `Collection ${collectionName} does not exist — declare it as AWS::OpenSearchServerless::Collection or call CreateCollection first.`);
240
+ }
241
+ return await this.routeDataPlane(req, region, collectionName, segments.slice(2));
242
+ }
243
+ catch (err) {
244
+ if (err instanceof OsError)
245
+ return osErrorResponse(err);
246
+ throw err;
247
+ }
248
+ }
249
+ async routeDataPlane(req, region, collection, rest) {
250
+ const method = req.method;
251
+ if (rest.length === 0) {
252
+ // The OpenSearch client's ping/info handshake.
253
+ if (method === 'HEAD')
254
+ return { status: 200 };
255
+ if (method === 'GET') {
256
+ return jsonResponse(200, {
257
+ name: collection,
258
+ cluster_name: collection,
259
+ cluster_uuid: this.collectionId(region, collection),
260
+ version: {
261
+ distribution: 'opensearch',
262
+ number: '2.11.0',
263
+ build_type: 'lss-self-engine',
264
+ minimum_wire_compatibility_version: '7.10.0',
265
+ minimum_index_compatibility_version: '7.0.0',
266
+ },
267
+ tagline: 'The LSS self engine, serving the OpenSearch REST API locally',
268
+ });
269
+ }
270
+ }
271
+ else if (rest[0].startsWith('_') && rest[0] !== '_all') {
272
+ // `_all` is an index expression, not a collection API.
273
+ return this.routeCollectionApi(req, region, collection, rest);
274
+ }
275
+ else {
276
+ return this.routeIndexApi(req, region, collection, rest);
277
+ }
278
+ throw this.unsupported(req);
279
+ }
280
+ // Collection-level _apis (no index in the path).
281
+ async routeCollectionApi(req, region, collection, rest) {
282
+ const method = req.method;
283
+ const api = rest[0];
284
+ if (api === '_search' && (method === 'GET' || method === 'POST') && rest.length === 1) {
285
+ return this.search(req, region, collection, await this.indexNames(region, collection));
286
+ }
287
+ if (api === '_count' && (method === 'GET' || method === 'POST') && rest.length === 1) {
288
+ return this.count(req, region, collection, await this.indexNames(region, collection));
289
+ }
290
+ if (api === '_bulk' && method === 'POST' && rest.length === 1) {
291
+ return this.bulk(req, region, collection, undefined);
292
+ }
293
+ if (api === '_refresh' && method === 'POST' && rest.length === 1) {
294
+ // Writes are visible immediately; acknowledged for client compatibility.
295
+ return jsonResponse(200, { _shards: { total: 1, successful: 1, failed: 0 } });
296
+ }
297
+ if (api === '_cat' && rest[1] === 'indices' && method === 'GET' && rest.length === 2) {
298
+ return this.catIndices(region, collection);
299
+ }
300
+ throw this.unsupported(req);
301
+ }
302
+ async routeIndexApi(req, region, collection, rest) {
303
+ const method = req.method;
304
+ const index = rest[0];
305
+ if (rest.length === 1) {
306
+ if (method === 'PUT')
307
+ return this.createIndex(req, region, collection, index);
308
+ if (method === 'DELETE')
309
+ return this.deleteIndex(region, collection, index);
310
+ if (method === 'GET')
311
+ return this.getIndex(region, collection, index);
312
+ if (method === 'HEAD') {
313
+ return { status: (await this.indexRecord(region, collection, index)) ? 200 : 404 };
314
+ }
315
+ throw this.unsupported(req);
316
+ }
317
+ const sub = rest[1];
318
+ if (sub === '_search' && (method === 'GET' || method === 'POST') && rest.length === 2) {
319
+ return this.search(req, region, collection, await this.resolveIndexPatterns(region, collection, index));
320
+ }
321
+ if (sub === '_count' && (method === 'GET' || method === 'POST') && rest.length === 2) {
322
+ return this.count(req, region, collection, await this.resolveIndexPatterns(region, collection, index));
323
+ }
324
+ if (sub === '_bulk' && method === 'POST' && rest.length === 2) {
325
+ return this.bulk(req, region, collection, index);
326
+ }
327
+ if (sub === '_refresh' && method === 'POST' && rest.length === 2) {
328
+ return jsonResponse(200, { _shards: { total: 1, successful: 1, failed: 0 } });
329
+ }
330
+ if (sub === '_mapping' && rest.length === 2) {
331
+ if (method === 'GET')
332
+ return this.getMapping(region, collection, index);
333
+ if (method === 'PUT')
334
+ return this.putMapping(req, region, collection, index);
335
+ throw this.unsupported(req);
336
+ }
337
+ if (sub === '_doc') {
338
+ if (rest.length === 2 && method === 'POST') {
339
+ return this.indexDoc(req, region, collection, index, randomUUID(), false);
340
+ }
341
+ if (rest.length === 3) {
342
+ const id = rest[2];
343
+ if (method === 'PUT' || method === 'POST')
344
+ return this.indexDoc(req, region, collection, index, id, false);
345
+ if (method === 'GET')
346
+ return this.getDoc(region, collection, index, id);
347
+ if (method === 'HEAD')
348
+ return this.docHead(region, collection, index, id);
349
+ if (method === 'DELETE')
350
+ return this.deleteDoc(region, collection, index, id);
351
+ }
352
+ throw this.unsupported(req);
353
+ }
354
+ if (sub === '_create' && rest.length === 3 && (method === 'PUT' || method === 'POST')) {
355
+ return this.indexDoc(req, region, collection, index, rest[2], true);
356
+ }
357
+ if (sub === '_update' && rest.length === 3 && method === 'POST') {
358
+ return this.updateDoc(req, region, collection, index, rest[2]);
359
+ }
360
+ throw this.unsupported(req);
361
+ }
362
+ unsupported(req) {
363
+ return new OsError(400, 'illegal_argument_exception', `${req.method} ${req.rawPath} is not supported by the LSS self engine — see docs/SELF_ENGINE.md#coverage, ` +
364
+ 'or set selfEngine.fallbackEndpoint to forward unimplemented operations');
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // Index management
368
+ // ---------------------------------------------------------------------------
369
+ async createIndex(req, region, collection, index) {
370
+ this.assertIndexName(index);
371
+ const indices = await this.indices(region);
372
+ if (indices.get(indexKey(collection, index))) {
373
+ throw new OsError(400, 'resource_already_exists_exception', `index [${index}] already exists`);
374
+ }
375
+ const body = parseBodyJson(req, 'mapper_parsing_exception');
376
+ this.putIndexRecord(indices, collection, index, body);
377
+ return jsonResponse(200, { acknowledged: true, shards_acknowledged: true, index });
378
+ }
379
+ putIndexRecord(indices, collection, index, body) {
380
+ const record = {
381
+ collection,
382
+ index,
383
+ mappings: body.mappings ?? {},
384
+ settings: body.settings ?? {},
385
+ createdAt: new Date().toISOString(),
386
+ };
387
+ indices.set(indexKey(collection, index), record);
388
+ return record;
389
+ }
390
+ async deleteIndex(region, collection, index) {
391
+ const indices = await this.indices(region);
392
+ if (!indices.get(indexKey(collection, index)))
393
+ throw this.indexNotFound(index);
394
+ await this.docsTable(region, collection, index).destroy();
395
+ indices.delete(indexKey(collection, index));
396
+ return jsonResponse(200, { acknowledged: true });
397
+ }
398
+ async getIndex(region, collection, index) {
399
+ const record = await this.indexRecord(region, collection, index);
400
+ if (!record)
401
+ throw this.indexNotFound(index);
402
+ return jsonResponse(200, {
403
+ [index]: { aliases: {}, mappings: record.mappings, settings: record.settings },
404
+ });
405
+ }
406
+ async getMapping(region, collection, index) {
407
+ const record = await this.indexRecord(region, collection, index);
408
+ if (!record)
409
+ throw this.indexNotFound(index);
410
+ return jsonResponse(200, { [index]: { mappings: record.mappings } });
411
+ }
412
+ async putMapping(req, region, collection, index) {
413
+ const indices = await this.indices(region);
414
+ const record = indices.get(indexKey(collection, index));
415
+ if (!record)
416
+ throw this.indexNotFound(index);
417
+ const body = parseBodyJson(req, 'mapper_parsing_exception');
418
+ const properties = {
419
+ ...(record.mappings.properties ?? {}),
420
+ ...(body.properties ?? {}),
421
+ };
422
+ indices.set(indexKey(collection, index), { ...record, mappings: { ...record.mappings, ...body, properties } });
423
+ return jsonResponse(200, { acknowledged: true });
424
+ }
425
+ async catIndices(region, collection) {
426
+ const names = await this.indexNames(region, collection);
427
+ const rows = [];
428
+ for (const index of names) {
429
+ const docs = await this.docs(region, collection, index);
430
+ rows.push({
431
+ health: 'green',
432
+ status: 'open',
433
+ index,
434
+ uuid: this.collectionId(region, `${collection}/${index}`),
435
+ pri: '1',
436
+ rep: '0',
437
+ 'docs.count': String(docs.size()),
438
+ 'docs.deleted': '0',
439
+ 'store.size': `${docs.approxBytes()}b`,
440
+ 'pri.store.size': `${docs.approxBytes()}b`,
441
+ });
442
+ }
443
+ return jsonResponse(200, rows);
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // Document CRUD
447
+ // ---------------------------------------------------------------------------
448
+ async indexDoc(req, region, collection, index, id, createOnly) {
449
+ this.assertIndexName(index);
450
+ if (req.body.length === 0) {
451
+ throw new OsError(400, 'mapper_parsing_exception', 'request body is required for indexing a document');
452
+ }
453
+ const source = parseBodyJson(req, 'mapper_parsing_exception');
454
+ const indices = await this.indices(region);
455
+ // Indexing into an absent index creates it, like OpenSearch's default
456
+ // auto-create behavior.
457
+ if (!indices.get(indexKey(collection, index)))
458
+ this.putIndexRecord(indices, collection, index, {});
459
+ const docs = await this.docs(region, collection, index);
460
+ const existing = docs.get(id);
461
+ if (existing && createOnly) {
462
+ throw new OsError(409, 'version_conflict_engine_exception', `[${id}]: version conflict, document already exists (current version [${existing.version}])`);
463
+ }
464
+ const version = (existing?.version ?? 0) + 1;
465
+ docs.put(id, structuredClone({ source, version }));
466
+ return jsonResponse(existing ? 200 : 201, {
467
+ _index: index,
468
+ _id: id,
469
+ _version: version,
470
+ result: existing ? 'updated' : 'created',
471
+ _shards: { total: 1, successful: 1, failed: 0 },
472
+ _seq_no: version - 1,
473
+ _primary_term: 1,
474
+ });
475
+ }
476
+ async getDoc(region, collection, index, id) {
477
+ if (!(await this.indexRecord(region, collection, index)))
478
+ throw this.indexNotFound(index);
479
+ const docs = await this.docs(region, collection, index);
480
+ const record = docs.get(id);
481
+ if (!record)
482
+ return jsonResponse(404, { _index: index, _id: id, found: false });
483
+ return jsonResponse(200, {
484
+ _index: index,
485
+ _id: id,
486
+ _version: record.version,
487
+ _seq_no: record.version - 1,
488
+ _primary_term: 1,
489
+ found: true,
490
+ _source: structuredClone(record.source),
491
+ });
492
+ }
493
+ async docHead(region, collection, index, id) {
494
+ if (!(await this.indexRecord(region, collection, index)))
495
+ return { status: 404 };
496
+ const docs = await this.docs(region, collection, index);
497
+ return { status: docs.has(id) ? 200 : 404 };
498
+ }
499
+ async deleteDoc(region, collection, index, id) {
500
+ if (!(await this.indexRecord(region, collection, index)))
501
+ throw this.indexNotFound(index);
502
+ const docs = await this.docs(region, collection, index);
503
+ const record = docs.get(id);
504
+ const payload = {
505
+ _index: index,
506
+ _id: id,
507
+ _version: (record?.version ?? 0) + 1,
508
+ result: record ? 'deleted' : 'not_found',
509
+ _shards: { total: 1, successful: 1, failed: 0 },
510
+ _seq_no: record?.version ?? 0,
511
+ _primary_term: 1,
512
+ };
513
+ if (!record)
514
+ return jsonResponse(404, payload);
515
+ docs.delete(id);
516
+ return jsonResponse(200, payload);
517
+ }
518
+ async updateDoc(req, region, collection, index, id) {
519
+ if (!(await this.indexRecord(region, collection, index)))
520
+ throw this.indexNotFound(index);
521
+ const body = parseBodyJson(req, 'x_content_parse_exception');
522
+ if (body.script !== undefined) {
523
+ throw new OsError(400, 'illegal_argument_exception', 'scripted updates are not supported by the LSS self engine — send a partial "doc" instead');
524
+ }
525
+ const patch = body.doc;
526
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
527
+ throw new OsError(400, 'x_content_parse_exception', 'the _update body requires a "doc" object');
528
+ }
529
+ const docs = await this.docs(region, collection, index);
530
+ const existing = docs.get(id);
531
+ if (!existing) {
532
+ const upsert = body.doc_as_upsert === true ? patch : body.upsert;
533
+ if (upsert === null || typeof upsert !== 'object' || Array.isArray(upsert)) {
534
+ throw new OsError(404, 'document_missing_exception', `[${id}]: document missing`);
535
+ }
536
+ docs.put(id, structuredClone({ source: upsert, version: 1 }));
537
+ return jsonResponse(201, {
538
+ _index: index, _id: id, _version: 1, result: 'created',
539
+ _shards: { total: 1, successful: 1, failed: 0 }, _seq_no: 0, _primary_term: 1,
540
+ });
541
+ }
542
+ const merged = mergeDoc(existing.source, patch);
543
+ const version = existing.version + 1;
544
+ docs.put(id, structuredClone({ source: merged, version }));
545
+ return jsonResponse(200, {
546
+ _index: index, _id: id, _version: version, result: 'updated',
547
+ _shards: { total: 1, successful: 1, failed: 0 }, _seq_no: version - 1, _primary_term: 1,
548
+ });
549
+ }
550
+ // ---------------------------------------------------------------------------
551
+ // _bulk (NDJSON)
552
+ // ---------------------------------------------------------------------------
553
+ async bulk(req, region, collection, defaultIndex) {
554
+ const lines = req.body.toString('utf8').split('\n').filter(line => line.trim().length > 0);
555
+ const items = [];
556
+ let errors = false;
557
+ let cursor = 0;
558
+ while (cursor < lines.length) {
559
+ let action;
560
+ try {
561
+ action = JSON.parse(lines[cursor]);
562
+ }
563
+ catch {
564
+ throw new OsError(400, 'illegal_argument_exception', `Malformed bulk action at line ${cursor + 1}: not valid JSON`);
565
+ }
566
+ cursor++;
567
+ const [op, rawMeta] = Object.entries(action)[0] ?? [];
568
+ if (!op || !['index', 'create', 'update', 'delete'].includes(op)) {
569
+ throw new OsError(400, 'illegal_argument_exception', `Malformed bulk action at line ${cursor}: expected index, create, update or delete`);
570
+ }
571
+ const meta = (rawMeta ?? {});
572
+ const index = typeof meta._index === 'string' ? meta._index : defaultIndex;
573
+ // Clients send numeric _ids too; OpenSearch coerces them to strings.
574
+ // Auto-generation only applies to index/create — update/delete against
575
+ // a random id would silently target nothing.
576
+ const givenId = typeof meta._id === 'string' || typeof meta._id === 'number' ? String(meta._id) : undefined;
577
+ if (givenId === undefined && (op === 'update' || op === 'delete')) {
578
+ throw new OsError(400, 'illegal_argument_exception', `Bulk action "${op}" at line ${cursor} requires an _id`);
579
+ }
580
+ const id = givenId ?? randomUUID();
581
+ let payload = Buffer.alloc(0);
582
+ if (op !== 'delete') {
583
+ if (cursor >= lines.length) {
584
+ throw new OsError(400, 'illegal_argument_exception', `Bulk action "${op}" at line ${cursor} is missing its document line`);
585
+ }
586
+ payload = Buffer.from(lines[cursor], 'utf8');
587
+ cursor++;
588
+ }
589
+ if (!index) {
590
+ errors = true;
591
+ items.push(bulkItem(op, '_unknown', id, 400, { type: 'illegal_argument_exception', reason: 'no index specified for bulk action' }));
592
+ continue;
593
+ }
594
+ try {
595
+ const subReq = { ...req, body: payload };
596
+ const response = op === 'delete' ? await this.deleteDoc(region, collection, index, id)
597
+ : op === 'update' ? await this.updateDoc(subReq, region, collection, index, id)
598
+ : await this.indexDoc(subReq, region, collection, index, id, op === 'create');
599
+ const body = JSON.parse(String(response.body));
600
+ // A delete of a missing document reports status 404 in its slot but is
601
+ // not an error, matching OpenSearch — the flag tracks failed writes.
602
+ if (response.status >= 400 && body.result !== 'not_found')
603
+ errors = true;
604
+ items.push({ [op]: { ...body, status: response.status } });
605
+ }
606
+ catch (err) {
607
+ if (!(err instanceof OsError))
608
+ throw err;
609
+ errors = true;
610
+ items.push(bulkItem(op, index, id, err.status, { type: err.type, reason: err.message }));
611
+ }
612
+ }
613
+ if (items.length === 0) {
614
+ throw new OsError(400, 'illegal_argument_exception', 'The bulk request must contain at least one action');
615
+ }
616
+ return jsonResponse(200, { took: 0, errors, items });
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // _search / _count
620
+ // ---------------------------------------------------------------------------
621
+ async search(req, region, collection, indexNames) {
622
+ const body = parseBodyJson(req, 'x_content_parse_exception');
623
+ // Answering a scroll/PIT request without a _scroll_id would silently
624
+ // truncate the client's pagination loop — fail loudly instead.
625
+ if (req.query.scroll !== undefined || body.scroll !== undefined || body.pit !== undefined) {
626
+ throw new OsError(400, 'illegal_argument_exception', 'scroll and point-in-time pagination are not supported by the LSS self engine — page with from/size');
627
+ }
628
+ const query = this.effectiveQuery(req, body);
629
+ let hits;
630
+ let sortSpec;
631
+ let aggregations;
632
+ try {
633
+ const all = await this.gatherDocs(region, collection, indexNames);
634
+ hits = all.filter(doc => matchesQuery(query, doc));
635
+ sortSpec = normalizeSort(body.sort);
636
+ // Aggregations run over every match, not just the returned page.
637
+ aggregations = body.aggs !== undefined || body.aggregations !== undefined
638
+ ? computeAggregations(body.aggs ?? body.aggregations, hits)
639
+ : undefined;
640
+ }
641
+ catch (err) {
642
+ if (err instanceof OsError)
643
+ throw err;
644
+ throw new OsError(400, 'search_phase_execution_exception', err instanceof Error ? err.message : String(err));
645
+ }
646
+ const total = hits.length;
647
+ const sorted = sortHits(hits, sortSpec);
648
+ const from = numberParam(req.query.from ?? body.from, 0);
649
+ const size = numberParam(req.query.size ?? body.size, DEFAULT_SEARCH_SIZE);
650
+ const page = sorted.slice(from, from + size);
651
+ const sourceSpec = body._source;
652
+ return jsonResponse(200, {
653
+ took: 0,
654
+ timed_out: false,
655
+ _shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
656
+ hits: {
657
+ total: { value: total, relation: 'eq' },
658
+ max_score: page.length > 0 ? 1 : null,
659
+ hits: page.map(doc => {
660
+ let filtered;
661
+ try {
662
+ filtered = filterSource(doc.source, sourceSpec);
663
+ }
664
+ catch (err) {
665
+ throw new OsError(400, 'x_content_parse_exception', err instanceof Error ? err.message : String(err));
666
+ }
667
+ return {
668
+ _index: doc.index,
669
+ _id: doc.id,
670
+ _score: 1,
671
+ ...(filtered !== undefined ? { _source: filtered } : {}),
672
+ };
673
+ }),
674
+ },
675
+ ...(aggregations !== undefined ? { aggregations } : {}),
676
+ });
677
+ }
678
+ async count(req, region, collection, indexNames) {
679
+ const body = parseBodyJson(req, 'x_content_parse_exception');
680
+ const query = this.effectiveQuery(req, body);
681
+ let count;
682
+ try {
683
+ const all = await this.gatherDocs(region, collection, indexNames);
684
+ count = all.filter(doc => matchesQuery(query, doc)).length;
685
+ }
686
+ catch (err) {
687
+ if (err instanceof OsError)
688
+ throw err;
689
+ throw new OsError(400, 'search_phase_execution_exception', err instanceof Error ? err.message : String(err));
690
+ }
691
+ return jsonResponse(200, { count, _shards: { total: 1, successful: 1, skipped: 0, failed: 0 } });
692
+ }
693
+ // `?q=` supports the two shapes people actually type into curl: bare text
694
+ // (tokenized match over every top-level field) and a single field:value pair.
695
+ effectiveQuery(req, body) {
696
+ const q = req.query.q;
697
+ if (q === undefined)
698
+ return body.query;
699
+ if (body.query !== undefined) {
700
+ throw new OsError(400, 'illegal_argument_exception', 'Specify either the q parameter or a request-body query, not both');
701
+ }
702
+ const colon = q.indexOf(':');
703
+ if (colon > 0) {
704
+ return { match: { [q.slice(0, colon)]: q.slice(colon + 1) } };
705
+ }
706
+ return { multi_match: { query: q, fields: ['*'] } };
707
+ }
708
+ async gatherDocs(region, collection, indexNames) {
709
+ const result = [];
710
+ for (const index of indexNames) {
711
+ const docs = await this.docs(region, collection, index);
712
+ for (const [id, value] of docs.entries()) {
713
+ const record = value;
714
+ result.push({ index, id, source: structuredClone(record.source) });
715
+ }
716
+ }
717
+ return result;
718
+ }
719
+ // ---------------------------------------------------------------------------
720
+ // Shared helpers
721
+ // ---------------------------------------------------------------------------
722
+ async collections(region) {
723
+ const catalog = this.ctx.store.catalog(`aoss/${region}/collections`);
724
+ await catalog.load();
725
+ return catalog;
726
+ }
727
+ async indices(region) {
728
+ const catalog = this.ctx.store.catalog(`aoss/${region}/indices`);
729
+ await catalog.load();
730
+ return catalog;
731
+ }
732
+ docsTable(region, collection, index) {
733
+ return this.ctx.store.table(`aoss/${region}/${collection}/${index}/docs`);
734
+ }
735
+ // The idle sweeper may dehydrate between requests — re-fetch and re-hydrate
736
+ // on every access (hydrate() is a no-op when already resident).
737
+ async docs(region, collection, index) {
738
+ const table = this.docsTable(region, collection, index);
739
+ await table.hydrate();
740
+ return table;
741
+ }
742
+ async indexRecord(region, collection, index) {
743
+ const indices = await this.indices(region);
744
+ return indices.get(indexKey(collection, index));
745
+ }
746
+ async indexNames(region, collection) {
747
+ const indices = await this.indices(region);
748
+ return indices.values()
749
+ .filter(record => record.collection === collection)
750
+ .map(record => record.index)
751
+ .sort();
752
+ }
753
+ // Search/count accept comma-separated names, `*` wildcards and `_all`.
754
+ // A concrete (non-wildcard) name that doesn't exist is a 404, like
755
+ // OpenSearch; wildcards matching nothing simply contribute no indices.
756
+ async resolveIndexPatterns(region, collection, expression) {
757
+ const known = await this.indexNames(region, collection);
758
+ const resolved = new Set();
759
+ for (const pattern of expression.split(',').filter(part => part.length > 0)) {
760
+ if (pattern === '_all' || pattern === '*') {
761
+ known.forEach(name => resolved.add(name));
762
+ }
763
+ else if (pattern.includes('*')) {
764
+ // Everything except '*' is literal — '?' included, so a stray '?'
765
+ // can't become a regex quantifier (or crash compilation).
766
+ const regex = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
767
+ known.filter(name => regex.test(name)).forEach(name => resolved.add(name));
768
+ }
769
+ else {
770
+ if (!known.includes(pattern))
771
+ throw this.indexNotFound(pattern);
772
+ resolved.add(pattern);
773
+ }
774
+ }
775
+ return [...resolved].sort();
776
+ }
777
+ assertIndexName(index) {
778
+ if (!INDEX_NAME.test(index) || index.startsWith('.')) {
779
+ throw new OsError(400, 'invalid_index_name_exception', `Invalid index name [${index}]: must be lowercase alphanumerics plus . _ - and must not start with _, - or .`);
780
+ }
781
+ }
782
+ indexNotFound(index) {
783
+ return new OsError(404, 'index_not_found_exception', `no such index [${index}]`);
784
+ }
785
+ collectionEndpoint(name) {
786
+ return `${this.ctx.endpoint()}/_aoss/${name}`;
787
+ }
788
+ collectionArn(region, id) {
789
+ return `arn:aws:aoss:${region}:${this.ctx.config.account}:collection/${id}`;
790
+ }
791
+ // Deterministic id so BatchGetCollection/DeleteCollection round-trip by id
792
+ // across engine restarts (real AOSS ids are random 20-char strings).
793
+ collectionId(region, name) {
794
+ return createHash('sha1').update(`${this.ctx.config.account}/${region}/${name}`).digest('hex').slice(0, 20);
795
+ }
796
+ }
797
+ function indexKey(collection, index) {
798
+ return `${collection}|${index}`;
799
+ }
800
+ // from/size arrive as query-string strings or body numbers; anything not a
801
+ // non-negative integer falls back to the default rather than slicing weirdly.
802
+ function numberParam(raw, fallback) {
803
+ if (raw === undefined)
804
+ return fallback;
805
+ const value = Number(raw);
806
+ return Number.isInteger(value) && value >= 0 ? value : fallback;
807
+ }
808
+ function bulkItem(op, index, id, status, error) {
809
+ return { [op]: { _index: index, _id: id, status, error } };
810
+ }
811
+ //# sourceMappingURL=index.js.map