@bandeira-tech/b3nd-save 0.8.1 → 0.13.1

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 (161) hide show
  1. package/esm/clients/save-client.d.ts +63 -29
  2. package/esm/clients/save-client.d.ts.map +1 -1
  3. package/esm/clients/save-client.js +148 -68
  4. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/mod.d.ts +1 -1
  5. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/mod.d.ts.map +1 -0
  6. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/client-console/client.d.ts.map +1 -1
  7. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/encoding/encoding.d.ts.map +1 -1
  8. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/encrypt/mod.d.ts +1 -0
  9. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/encrypt/mod.d.ts.map +1 -0
  10. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/encrypt/mod.js +1 -0
  11. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/encrypt/utils.d.ts +6 -0
  12. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/encrypt/utils.d.ts.map +1 -0
  13. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/encrypt/utils.js +12 -0
  14. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/functional-client/functional-client.d.ts.map +1 -1
  15. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/match-pattern/match-pattern.d.ts.map +1 -1
  16. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/decorators.d.ts.map +1 -1
  17. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/network.d.ts.map +1 -1
  18. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/peer.d.ts.map +1 -1
  19. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/flood.d.ts.map +1 -1
  20. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/path-vector.d.ts +3 -1
  21. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/network/policies/path-vector.d.ts.map +1 -0
  22. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/path-vector.js +3 -1
  23. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/tell-and-read.d.ts.map +1 -1
  24. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/types.d.ts.map +1 -1
  25. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/observe-emitter/observe-emitter.d.ts.map +1 -1
  26. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/connection.d.ts +13 -5
  27. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/rig/connection.d.ts.map +1 -0
  28. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/connection.js +1 -1
  29. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/events.d.ts.map +1 -1
  30. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/hooks.d.ts.map +1 -1
  31. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/identity.d.ts.map +1 -1
  32. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/operation-handle.d.ts.map +1 -1
  33. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/reactions.d.ts.map +1 -1
  34. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/rig.d.ts +3 -0
  35. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/rig/rig.d.ts.map +1 -0
  36. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/rig.js +46 -4
  37. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/types.d.ts +4 -4
  38. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/rig/types.d.ts.map +1 -0
  39. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/types/types.d.ts +119 -80
  40. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/types/types.d.ts.map +1 -0
  41. package/esm/dispatch.d.ts +116 -3
  42. package/esm/dispatch.d.ts.map +1 -1
  43. package/esm/dispatch.js +381 -5
  44. package/esm/elasticsearch/store.d.ts +124 -25
  45. package/esm/elasticsearch/store.d.ts.map +1 -1
  46. package/esm/elasticsearch/store.js +451 -92
  47. package/esm/entity-store.d.ts +1 -1
  48. package/esm/errors.d.ts +1 -1
  49. package/esm/errors.js +1 -1
  50. package/esm/fs/mod.d.ts +26 -0
  51. package/esm/fs/mod.d.ts.map +1 -1
  52. package/esm/fs/store.d.ts +100 -23
  53. package/esm/fs/store.d.ts.map +1 -1
  54. package/esm/fs/store.js +250 -66
  55. package/esm/glob.d.ts +114 -0
  56. package/esm/glob.d.ts.map +1 -0
  57. package/esm/glob.js +352 -0
  58. package/esm/identifiers.d.ts +18 -0
  59. package/esm/identifiers.d.ts.map +1 -0
  60. package/esm/identifiers.js +19 -0
  61. package/esm/indexeddb/fields.d.ts +32 -0
  62. package/esm/indexeddb/fields.d.ts.map +1 -0
  63. package/esm/indexeddb/fields.js +51 -0
  64. package/esm/indexeddb/store.d.ts +72 -17
  65. package/esm/indexeddb/store.d.ts.map +1 -1
  66. package/esm/indexeddb/store.js +362 -106
  67. package/esm/ipfs/mod.d.ts +19 -0
  68. package/esm/ipfs/mod.d.ts.map +1 -1
  69. package/esm/ipfs/mod.js +19 -0
  70. package/esm/ipfs/store.d.ts +82 -21
  71. package/esm/ipfs/store.d.ts.map +1 -1
  72. package/esm/ipfs/store.js +269 -68
  73. package/esm/json-fields.d.ts +61 -0
  74. package/esm/json-fields.d.ts.map +1 -0
  75. package/esm/json-fields.js +121 -0
  76. package/esm/localstorage/store.d.ts +89 -21
  77. package/esm/localstorage/store.d.ts.map +1 -1
  78. package/esm/localstorage/store.js +247 -54
  79. package/esm/memory/store.d.ts +35 -4
  80. package/esm/memory/store.d.ts.map +1 -1
  81. package/esm/memory/store.js +69 -47
  82. package/esm/mod.d.ts +2 -0
  83. package/esm/mod.d.ts.map +1 -1
  84. package/esm/mod.js +1 -0
  85. package/esm/mongo/fields.d.ts +53 -0
  86. package/esm/mongo/fields.d.ts.map +1 -0
  87. package/esm/mongo/fields.js +71 -0
  88. package/esm/mongo/mod.d.ts +19 -4
  89. package/esm/mongo/mod.d.ts.map +1 -1
  90. package/esm/mongo/mod.js +4 -2
  91. package/esm/mongo/store.d.ts +114 -25
  92. package/esm/mongo/store.d.ts.map +1 -1
  93. package/esm/mongo/store.js +355 -102
  94. package/esm/postgres/store.d.ts +50 -1
  95. package/esm/postgres/store.d.ts.map +1 -1
  96. package/esm/postgres/store.js +133 -13
  97. package/esm/read.d.ts +54 -7
  98. package/esm/read.d.ts.map +1 -1
  99. package/esm/read.js +141 -16
  100. package/esm/s3/store.d.ts +125 -23
  101. package/esm/s3/store.d.ts.map +1 -1
  102. package/esm/s3/store.js +313 -57
  103. package/esm/sqlite/columns.d.ts +33 -0
  104. package/esm/sqlite/columns.d.ts.map +1 -0
  105. package/esm/sqlite/columns.js +51 -0
  106. package/esm/sqlite/store.d.ts +73 -25
  107. package/esm/sqlite/store.d.ts.map +1 -1
  108. package/esm/sqlite/store.js +411 -107
  109. package/esm/types.d.ts +1 -1
  110. package/esm/url.d.ts +122 -19
  111. package/esm/url.d.ts.map +1 -1
  112. package/esm/url.js +218 -24
  113. package/esm/walk-via-ls.d.ts +122 -0
  114. package/esm/walk-via-ls.d.ts.map +1 -0
  115. package/esm/walk-via-ls.js +178 -0
  116. package/package.json +1 -1
  117. package/esm/byte-entity-shim.d.ts +0 -47
  118. package/esm/byte-entity-shim.d.ts.map +0 -1
  119. package/esm/byte-entity-shim.js +0 -138
  120. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.d.ts.map +0 -1
  121. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/encrypt/mod.d.ts.map +0 -1
  122. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/network/policies/path-vector.d.ts.map +0 -1
  123. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/connection.d.ts.map +0 -1
  124. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/rig.d.ts.map +0 -1
  125. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/rig/types.d.ts.map +0 -1
  126. package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.d.ts.map +0 -1
  127. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/mod.js +0 -0
  128. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/client-console/client.d.ts +0 -0
  129. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/client-console/client.js +0 -0
  130. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/encoding/encoding.d.ts +0 -0
  131. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/encoding/encoding.js +0 -0
  132. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/functional-client/functional-client.d.ts +0 -0
  133. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/functional-client/functional-client.js +0 -0
  134. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/match-pattern/match-pattern.d.ts +0 -0
  135. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/match-pattern/match-pattern.js +0 -0
  136. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/decorators.d.ts +0 -0
  137. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/decorators.js +0 -0
  138. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/network.d.ts +0 -0
  139. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/network.js +0 -0
  140. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/peer.d.ts +0 -0
  141. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/peer.js +0 -0
  142. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/flood.d.ts +0 -0
  143. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/flood.js +0 -0
  144. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/tell-and-read.d.ts +0 -0
  145. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/policies/tell-and-read.js +0 -0
  146. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/types.d.ts +0 -0
  147. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/network/types.js +0 -0
  148. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/observe-emitter/observe-emitter.d.ts +0 -0
  149. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/observe-emitter/observe-emitter.js +0 -0
  150. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/events.d.ts +0 -0
  151. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/events.js +0 -0
  152. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/hooks.d.ts +0 -0
  153. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/hooks.js +0 -0
  154. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/identity.d.ts +0 -0
  155. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/identity.js +0 -0
  156. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/operation-handle.d.ts +0 -0
  157. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/operation-handle.js +0 -0
  158. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/reactions.d.ts +0 -0
  159. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/reactions.js +0 -0
  160. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/rig/types.js +0 -0
  161. /package/esm/deps/jsr.io/@bandeira-tech/b3nd-core/{0.22.0 → 0.25.0}/src/types/types.js +0 -0
@@ -1,49 +1,112 @@
1
1
  /**
2
- * ElasticsearchStore — Elasticsearch implementation of Store.
2
+ * ElasticsearchStore — Elasticsearch implementation of `EntityStore`.
3
3
  *
4
- * Pure mechanical byte storage with no protocol awareness. URIs are
5
- * partitioned into one index per `protocol_hostname` pair, with the
6
- * path as the document `_id`. Payload bytes are base64-encoded into a
7
- * string field — ES has no native binary type that round-trips
8
- * arbitrary bytes through `_source`.
4
+ * One backend, many entities. Per-entity index naming partitions
5
+ * documents in a way that keeps queries simple:
9
6
  *
10
- * `fn=ls` / `fn=count` are pushed down via an ES regex query that
11
- * enforces the shallow-direct-leaves contract: `<docPrefix>[^/]+`.
12
- * Lucene regex queries are auto-anchored, so the pattern must match
13
- * the full `_id`.
7
+ * - `BYTES_ENTITY` index `{indexPrefix}_{protocol}_{hostname}`,
8
+ * docId = uri path. Document `_source` carries `payload` (base64)
9
+ * and a mirrored `path` field that `ls`/`count` regex-queries hit
10
+ * (ES 8 disallows regex on `_id`). This is the original layout —
11
+ * no migration.
12
+ * - any other schema → index
13
+ * `{indexPrefix}_{entityName}_{protocol}_{hostname}`, same docId
14
+ * convention. Document `_source` carries the record fields encoded
15
+ * per canonical `TYPE_TAGS` (bytes → base64, bigint → string,
16
+ * timestamp → ISO-8601 — see `./fields.ts`) plus the mirrored
17
+ * `path` field.
18
+ *
19
+ * ## Lifecycle
20
+ *
21
+ * `entitySupport(schema)` is pure — it plans the field set and computes
22
+ * a collision signature. `provisionEntity(meta)` writes a tiny meta
23
+ * doc to `{indexPrefix}__meta__/entities` keyed by entity name, body
24
+ * `{signature}`; subsequent `entityStatus(meta)` reads it back. The
25
+ * meta doc is also used by `status()` to list provisioned entities.
26
+ *
27
+ * ## Read params
28
+ *
29
+ * `fn=ls` / `fn=count` push down to an ES regex query against the
30
+ * mirrored `path.keyword` field with the shallow-direct-leaves
31
+ * predicate `<docPrefix>[^/]+`. Standard params (`limit`/`page`/
32
+ * `sortBy=uri`/`sortOrder`/`format`) translate to `size`/`from`/`sort`,
33
+ * with `_source: false` for the `format=uris` fast path.
34
+ *
35
+ * `fn=find` (v2 §3.3) reuses the regex query shape but composes the
36
+ * body from `saveGlobToRegexBody(pattern)` directly — `**` → `.*`
37
+ * (crosses `/`), so the same query that backs `ls` walks the entire
38
+ * subtree. The query stays prefix-anchored on `path.keyword` (a
39
+ * `keyword` field with a term dictionary), which Lucene can serve via
40
+ * its `RegexpQuery` using DFA traversal of the term dictionary;
41
+ * prefix-anchored regexes are the fast case (the engine restricts the
42
+ * scan to terms whose common prefix matches the regex's required
43
+ * prefix). The default `[^/]+` shallow body that `_leafQuery` falls
44
+ * back to when no pattern is set is NOT reachable from the find path:
45
+ * `parseUrl` guarantees `params.pattern` is populated whenever
46
+ * `fn=find` dispatches here (URI-embedded glob or the dual-accept
47
+ * `?pattern=` form).
48
+ *
49
+ * `pushDownCount: true` — ES has a native `_count` API that runs the
50
+ * same query shape without scoring or document materialisation. Deep
51
+ * counts (URLs containing `**`) route directly to `_count` with the
52
+ * `saveGlobToRegexBody`-composed regex; dispatch never walks via
53
+ * `handlers.find` to count.
14
54
  */
15
- import { bytesOnlyDelete, bytesOnlyEntitySupport, bytesOnlyRead, bytesOnlyWrite, } from "../byte-entity-shim.js";
16
- import { decodeBase64, encodeBase64 } from "../deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/mod.js";
55
+ import { decodeBase64, encodeBase64 } from "../deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/mod.js";
17
56
  import { dispatchRead } from "../dispatch.js";
18
57
  import { storageFailure } from "../errors.js";
19
58
  import { toBytes } from "../payload.js";
20
59
  import { validateReadParams } from "../read.js";
60
+ import { saveGlobToRegexBody } from "../glob.js";
61
+ import { BYTES_ENTITY, } from "../entity.js";
62
+ import { IDENTIFIER_PATTERN, isValidIdentifier } from "../identifiers.js";
63
+ import { computeSignature, decodeRecord, encodeRecord, planFields, } from "../json-fields.js";
21
64
  const STORE_NAME = "ElasticsearchStore";
65
+ const META_INDEX_SUFFIX = "__meta__";
22
66
  /** Escape characters that are special in Lucene regex syntax. */
23
67
  function escapeLuceneRegex(input) {
24
68
  return input.replace(/[.?+*|{}\[\]()"\\#@&<>~]/g, "\\$&");
25
69
  }
26
70
  /**
27
- * Parse a URI into an Elasticsearch index name and document ID.
28
- * `protocol://hostname/path` index: `prefix_protocol_hostname`,
29
- * docId: `path` (without leading slash).
71
+ * Parse a URI into the `(protocol, hostname, docId)` tuple. ES index
72
+ * naming gets layered on top by each entity meta.
30
73
  */
31
- function uriToIndexAndDocId(uri, indexPrefix) {
74
+ function uriParts(uri) {
32
75
  const url = new URL(uri);
33
- const protocol = url.protocol.replace(":", "");
34
- const hostname = url.hostname;
35
76
  return {
36
- index: `${indexPrefix}_${protocol}_${hostname}`,
77
+ protocol: url.protocol.replace(":", ""),
78
+ hostname: url.hostname,
37
79
  docId: url.pathname.substring(1),
38
80
  };
39
81
  }
40
- /** Reconstruct a URI from an index name + document ID. */
41
- function indexAndDocIdToUri(index, indexPrefix, docId) {
42
- const withoutPrefix = index.substring(indexPrefix.length + 1);
43
- const firstUnderscore = withoutPrefix.indexOf("_");
44
- const protocol = withoutPrefix.substring(0, firstUnderscore);
45
- const hostname = withoutPrefix.substring(firstUnderscore + 1);
46
- return `${protocol}://${hostname}/${docId}`;
82
+ function isBytesSchema(schema) {
83
+ return schema.name === BYTES_ENTITY.name;
84
+ }
85
+ /**
86
+ * Pick the right ES field name to sort on for a declared user field.
87
+ * Any string-encoded value (strings, base64-encoded bytes, decimal
88
+ * bigint strings, ISO timestamps) hits the dynamic `.keyword` subfield
89
+ * since ES 8+ disables fielddata on `text` by default; native numeric
90
+ * and boolean fields sort on the field directly; `json` fields aren't
91
+ * sortable, so we return `null` and let dispatch's post-sort fallback
92
+ * handle them.
93
+ */
94
+ function esSortField(meta, fieldName) {
95
+ const plan = meta.fields.find((f) => f.name === fieldName);
96
+ if (!plan)
97
+ return null;
98
+ switch (plan.tag) {
99
+ case "string":
100
+ case "bytes":
101
+ case "bigint":
102
+ case "timestamp":
103
+ return `${fieldName}.keyword`;
104
+ case "number":
105
+ case "boolean":
106
+ return fieldName;
107
+ default:
108
+ return null;
109
+ }
47
110
  }
48
111
  export class ElasticsearchStore {
49
112
  indexPrefix;
@@ -56,87 +119,292 @@ export class ElasticsearchStore {
56
119
  this.indexPrefix = indexPrefix;
57
120
  this.executor = executor;
58
121
  }
59
- // ── EntityStore surface ──────────────────────────────────────────
122
+ // ── Lifecycle ────────────────────────────────────────────────────
60
123
  entitySupport(schema) {
61
- return bytesOnlyEntitySupport(schema);
62
- }
63
- entityStatus(meta) {
64
- return Promise.resolve(meta.isBytes ? "live" : "unprovisioned");
65
- }
66
- provisionEntity(_meta) {
67
- return Promise.resolve();
68
- }
69
- write(meta, entries) {
70
- return bytesOnlyWrite(meta, STORE_NAME, entries, (e) => this._writeBytes(e));
124
+ if (isBytesSchema(schema)) {
125
+ const fields = [{ name: "payload", tag: "bytes" }];
126
+ return {
127
+ support: {
128
+ entity: schema.name,
129
+ supported: ["payload"],
130
+ unsupported: [],
131
+ },
132
+ indexInfix: "",
133
+ isBytes: true,
134
+ fields,
135
+ declared: new Set(["payload"]),
136
+ bytesFields: new Set(["payload"]),
137
+ signature: computeSignature(schema.name, fields),
138
+ };
139
+ }
140
+ if (!isValidIdentifier(schema.name)) {
141
+ throw new Error(`${STORE_NAME}: entity name '${schema.name}' must match ${IDENTIFIER_PATTERN.source}`);
142
+ }
143
+ const { fields, unsupported } = planFields(schema.fields);
144
+ const declared = new Set(fields.map((f) => f.name));
145
+ const bytesFields = new Set(fields.filter((f) => f.tag === "bytes").map((f) => f.name));
146
+ return {
147
+ support: {
148
+ entity: schema.name,
149
+ supported: fields.map((f) => f.name),
150
+ unsupported,
151
+ },
152
+ indexInfix: `${schema.name}_`,
153
+ isBytes: false,
154
+ fields,
155
+ declared,
156
+ bytesFields,
157
+ signature: computeSignature(schema.name, fields),
158
+ };
71
159
  }
72
- read(meta, urls) {
73
- return bytesOnlyRead(meta, STORE_NAME, urls, (u) => this._readBytes(u));
160
+ async entityStatus(meta) {
161
+ const recorded = await this._readMetaSignature(meta.support.entity);
162
+ if (recorded === null)
163
+ return "unprovisioned";
164
+ return recorded === meta.signature ? "live" : "unprovisioned";
74
165
  }
75
- delete(meta, uris) {
76
- return bytesOnlyDelete(meta, STORE_NAME, uris, (u) => this._deleteBytes(u));
166
+ async provisionEntity(meta) {
167
+ const recorded = await this._readMetaSignature(meta.support.entity);
168
+ if (recorded !== null) {
169
+ if (recorded !== meta.signature) {
170
+ throw new Error(`${STORE_NAME}: entity '${meta.support.entity}' is already ` +
171
+ `provisioned with a different shape`);
172
+ }
173
+ return;
174
+ }
175
+ await this.executor.index(this._metaIndex(), meta.support.entity, { signature: meta.signature });
77
176
  }
78
- // ── Byte ops (BYTES_ENTITY routing) ──────────────────────────────
79
- async _writeBytes(entries) {
177
+ // ── Write ────────────────────────────────────────────────────────
178
+ async write(meta, entries) {
179
+ if (entries.length === 0)
180
+ return [];
181
+ if ((await this._readMetaSignature(meta.support.entity)) !== meta.signature) {
182
+ return entries.map(({ uri }) => ({
183
+ success: false,
184
+ ...storageFailure(new Error(`${STORE_NAME}: entity '${meta.support.entity}' is not provisioned`), "Entity not provisioned", uri),
185
+ }));
186
+ }
80
187
  const results = [];
81
- for (const entry of entries) {
82
- try {
83
- const { index, docId } = uriToIndexAndDocId(entry.uri, this.indexPrefix);
84
- // We mirror docId into a `path` source field so `ls`/`count`
85
- // can run analyzed queries against it — ES 8 disallows
86
- // `regexp` (and prefix/wildcard) against the `_id` metadata
87
- // field. ES's default dynamic mapping for a string source
88
- // field produces `path` (text) + `path.keyword` (keyword);
89
- // we target `path.keyword` for exact-match regex push-down.
90
- const bytes = await toBytes(entry.payload);
91
- await this.executor.index(index, docId, {
92
- payload: encodeBase64(bytes),
93
- path: docId,
188
+ for (const { uri, record } of entries) {
189
+ const extras = Object.keys(record).filter((k) => !meta.declared.has(k));
190
+ if (extras.length > 0) {
191
+ results.push({
192
+ success: false,
193
+ ...storageFailure(new Error(`record contains keys not declared in schema '${meta.support.entity}': ${extras.join(", ")}`), "Schema mismatch", uri),
94
194
  });
195
+ continue;
196
+ }
197
+ try {
198
+ const { protocol, hostname, docId } = uriParts(uri);
199
+ const index = this._dataIndex(meta, protocol, hostname);
200
+ let body;
201
+ if (meta.isBytes) {
202
+ const payload = record.payload;
203
+ if (!(payload instanceof Uint8Array) &&
204
+ !(payload instanceof ReadableStream)) {
205
+ throw new Error("BYTES_ENTITY record.payload must be Uint8Array or ReadableStream");
206
+ }
207
+ const bytes = await toBytes(payload);
208
+ body = { payload: encodeBase64(bytes), path: docId };
209
+ }
210
+ else {
211
+ const normalised = await this._normaliseBytesFields(record, meta.bytesFields);
212
+ body = {
213
+ ...encodeRecord(meta.fields, normalised),
214
+ path: docId,
215
+ };
216
+ }
217
+ await this.executor.index(index, docId, body);
95
218
  results.push({ success: true });
96
219
  }
97
220
  catch (err) {
98
221
  results.push({
99
222
  success: false,
100
- ...storageFailure(err, "Write failed", entry.uri),
223
+ ...storageFailure(err, "Write failed", uri),
101
224
  });
102
225
  }
103
226
  }
104
227
  return results;
105
228
  }
106
- _readBytes(urls) {
229
+ // ── Read ─────────────────────────────────────────────────────────
230
+ read(meta, urls) {
107
231
  return dispatchRead(urls, STORE_NAME, {
108
- read: (p) => this._readOne(p.uri),
109
- ls: (p) => this._ls(p),
110
- count: (p) => this._count(p),
232
+ read: (p) => this._readOne(meta, p.uri),
233
+ ls: (p) => this._ls(meta, p),
234
+ count: (p) => this._count(meta, p),
235
+ find: (p) => this._find(meta, p),
236
+ pushDownPattern: true,
237
+ pushDownCursor: true,
238
+ pushDownSortBy: true,
239
+ pushDownFind: true,
240
+ pushDownCount: true,
111
241
  });
112
242
  }
113
- async _readOne(uri) {
114
- const { index, docId } = uriToIndexAndDocId(uri, this.indexPrefix);
115
- const doc = await this.executor.get(index, docId);
116
- if (!doc)
243
+ async _readOne(meta, uri) {
244
+ try {
245
+ const { protocol, hostname, docId } = uriParts(uri);
246
+ const index = this._dataIndex(meta, protocol, hostname);
247
+ const doc = await this.executor.get(index, docId);
248
+ if (!doc)
249
+ return undefined;
250
+ if (meta.isBytes) {
251
+ return { payload: decodeBase64(doc.payload) };
252
+ }
253
+ return decodeRecord(meta.fields, doc);
254
+ }
255
+ catch {
117
256
  return undefined;
118
- return decodeBase64(doc.payload);
257
+ }
119
258
  }
120
- _leafQuery(docPrefix) {
259
+ /**
260
+ * Lucene regex query for shallow direct-leaves under `docPrefix`.
261
+ * Pattern push-down: splice the glob's regex body in place of the
262
+ * default `[^/]+`. Cursor push-down: wrap the regex in a `bool`
263
+ * with a `range` on `path.keyword` (`gt` for asc, `lt` for desc).
264
+ */
265
+ _leafQuery(docPrefix, pattern, cursorPath, sortOrder) {
266
+ const body = pattern !== undefined ? saveGlobToRegexBody(pattern) : "[^/]+";
267
+ const regex = {
268
+ regexp: {
269
+ "path.keyword": `${escapeLuceneRegex(docPrefix)}${body}`,
270
+ },
271
+ };
272
+ if (cursorPath === undefined)
273
+ return regex;
274
+ const rangeKey = sortOrder === "desc" ? "lt" : "gt";
121
275
  return {
276
+ bool: {
277
+ must: [
278
+ regex,
279
+ { range: { "path.keyword": { [rangeKey]: cursorPath } } },
280
+ ],
281
+ },
282
+ };
283
+ }
284
+ async _ls(meta, parsed) {
285
+ validateReadParams(parsed.params, STORE_NAME);
286
+ const { params } = parsed;
287
+ const format = params.format ?? "full";
288
+ const { protocol, hostname, docId } = uriParts(parsed.uri);
289
+ const index = this._dataIndex(meta, protocol, hostname);
290
+ // Cursor URI uses the same protocol+hostname as the prefix (it's
291
+ // from a previous page of the same query), so just strip those
292
+ // off to get the path-keyword cursor value.
293
+ const cursorPath = params.cursor !== undefined
294
+ ? uriParts(params.cursor).docId
295
+ : undefined;
296
+ const body = {
297
+ query: this._leafQuery(docId, params.pattern, cursorPath, params.sortOrder),
298
+ };
299
+ // sortBy: "uri" sorts on `path.keyword` (uri is encoded as
300
+ // protocol+hostname+path; path.keyword is the comparable suffix
301
+ // we already index on). Any other value must be a declared field;
302
+ // dispatch only routes non-uri sortBy through here when
303
+ // pushDownSortBy is true, which it is for this backend.
304
+ //
305
+ // ES needs the right field name for sort: text fields are
306
+ // unsortable directly (fielddata is off by default in ES 8+) and
307
+ // must target the `.keyword` subfield instead. Numeric / date /
308
+ // boolean fields sort on the field itself. `json` fields aren't
309
+ // sortable, so we omit the sort clause and let dispatch's
310
+ // post-sort fallback handle them.
311
+ const dir = params.sortOrder === "desc" ? "desc" : "asc";
312
+ if (params.sortBy === "uri") {
313
+ body.sort = [{ "path.keyword": dir }];
314
+ }
315
+ else if (params.sortBy !== undefined && meta.declared.has(params.sortBy)) {
316
+ const esField = esSortField(meta, params.sortBy);
317
+ if (esField !== null)
318
+ body.sort = [{ [esField]: dir }];
319
+ }
320
+ if (params.limit !== undefined) {
321
+ const page = params.page ?? 1;
322
+ body.size = params.limit;
323
+ body.from = (page - 1) * params.limit;
324
+ }
325
+ else {
326
+ body.size = 10_000;
327
+ }
328
+ if (format === "uris")
329
+ body._source = false;
330
+ let result;
331
+ try {
332
+ result = await this.executor.search(index, body);
333
+ }
334
+ catch {
335
+ return format === "uris" ? [] : [];
336
+ }
337
+ if (format === "uris") {
338
+ return result.hits.map((hit) => `${protocol}://${hostname}/${hit._id}`);
339
+ }
340
+ return result.hits.map((hit) => {
341
+ const uri = `${protocol}://${hostname}/${hit._id}`;
342
+ if (!hit._source)
343
+ return [uri, undefined];
344
+ if (meta.isBytes) {
345
+ return [uri, { payload: decodeBase64(hit._source.payload) }];
346
+ }
347
+ return [uri, decodeRecord(meta.fields, hit._source)];
348
+ });
349
+ }
350
+ /**
351
+ * Lucene regex query for the deep walk (`fn=find`) under
352
+ * `docPrefix`. Mirrors `_leafQuery` but REQUIRES a pattern: parseUrl
353
+ * guarantees `params.pattern` is populated whenever fn=find
354
+ * dispatches here (URI-embedded glob or dual-accept `?pattern=`).
355
+ * If pattern is absent we throw rather than silently widening to
356
+ * `.*` — that would conflate "find everything" with a parser bug.
357
+ *
358
+ * The composed body uses `saveGlobToRegexBody` (which emits `.*` for
359
+ * `**`, crossing `/`), prepended with the escaped `docPrefix`. The
360
+ * resulting regex is prefix-anchored on the keyword field — Lucene
361
+ * restricts the term-dictionary scan to terms starting with the
362
+ * required literal prefix, so even broad `**` queries don't degrade
363
+ * to a full term scan.
364
+ */
365
+ _subtreeQuery(docPrefix, pattern, cursorPath, sortOrder) {
366
+ if (pattern === undefined) {
367
+ throw new Error(`${STORE_NAME}: fn=find handler reached without params.pattern set ` +
368
+ `(parser invariant violated)`);
369
+ }
370
+ const body = saveGlobToRegexBody(pattern);
371
+ const regex = {
122
372
  regexp: {
123
- "path.keyword": `${escapeLuceneRegex(docPrefix)}[^/]+`,
373
+ "path.keyword": `${escapeLuceneRegex(docPrefix)}${body}`,
374
+ },
375
+ };
376
+ if (cursorPath === undefined)
377
+ return regex;
378
+ const rangeKey = sortOrder === "desc" ? "lt" : "gt";
379
+ return {
380
+ bool: {
381
+ must: [
382
+ regex,
383
+ { range: { "path.keyword": { [rangeKey]: cursorPath } } },
384
+ ],
124
385
  },
125
386
  };
126
387
  }
127
- async _ls(parsed) {
388
+ async _find(meta, parsed) {
128
389
  validateReadParams(parsed.params, STORE_NAME);
129
390
  const { params } = parsed;
130
391
  const format = params.format ?? "full";
131
- const { index, docId } = uriToIndexAndDocId(parsed.uri, this.indexPrefix);
392
+ const { protocol, hostname, docId } = uriParts(parsed.uri);
393
+ const index = this._dataIndex(meta, protocol, hostname);
394
+ const cursorPath = params.cursor !== undefined
395
+ ? uriParts(params.cursor).docId
396
+ : undefined;
132
397
  const body = {
133
- query: this._leafQuery(docId),
398
+ query: this._subtreeQuery(docId, params.pattern, cursorPath, params.sortOrder),
134
399
  };
400
+ const dir = params.sortOrder === "desc" ? "desc" : "asc";
135
401
  if (params.sortBy === "uri") {
136
- // Sort on the keyword subfield, same reason as the query above.
137
- body.sort = [{
138
- "path.keyword": params.sortOrder === "desc" ? "desc" : "asc",
139
- }];
402
+ body.sort = [{ "path.keyword": dir }];
403
+ }
404
+ else if (params.sortBy !== undefined && meta.declared.has(params.sortBy)) {
405
+ const esField = esSortField(meta, params.sortBy);
406
+ if (esField !== null)
407
+ body.sort = [{ [esField]: dir }];
140
408
  }
141
409
  if (params.limit !== undefined) {
142
410
  const page = params.page ?? 1;
@@ -148,27 +416,56 @@ export class ElasticsearchStore {
148
416
  }
149
417
  if (format === "uris")
150
418
  body._source = false;
151
- const result = await this.executor.search(index, body);
419
+ let result;
420
+ try {
421
+ result = await this.executor.search(index, body);
422
+ }
423
+ catch {
424
+ return format === "uris" ? [] : [];
425
+ }
152
426
  if (format === "uris") {
153
- return result.hits.map((hit) => indexAndDocIdToUri(index, this.indexPrefix, hit._id));
427
+ return result.hits.map((hit) => `${protocol}://${hostname}/${hit._id}`);
154
428
  }
155
- return result.hits.map((hit) => [
156
- indexAndDocIdToUri(index, this.indexPrefix, hit._id),
157
- hit._source ? decodeBase64(hit._source.payload) : undefined,
158
- ]);
429
+ return result.hits.map((hit) => {
430
+ const uri = `${protocol}://${hostname}/${hit._id}`;
431
+ if (!hit._source)
432
+ return [uri, undefined];
433
+ if (meta.isBytes) {
434
+ return [uri, { payload: decodeBase64(hit._source.payload) }];
435
+ }
436
+ return [uri, decodeRecord(meta.fields, hit._source)];
437
+ });
159
438
  }
160
- async _count(parsed) {
161
- if (parsed.params.pattern !== undefined) {
162
- throw new Error(`${STORE_NAME}: pattern filter not supported`);
439
+ async _count(meta, parsed) {
440
+ const { protocol, hostname, docId } = uriParts(parsed.uri);
441
+ const index = this._dataIndex(meta, protocol, hostname);
442
+ const cursorPath = parsed.params.cursor !== undefined
443
+ ? uriParts(parsed.params.cursor).docId
444
+ : undefined;
445
+ try {
446
+ return await this.executor.count(index, {
447
+ query: this._leafQuery(docId, parsed.params.pattern, cursorPath, parsed.params.sortOrder),
448
+ });
449
+ }
450
+ catch {
451
+ return 0;
163
452
  }
164
- const { index, docId } = uriToIndexAndDocId(parsed.uri, this.indexPrefix);
165
- return await this.executor.count(index, { query: this._leafQuery(docId) });
166
453
  }
167
- async _deleteBytes(uris) {
454
+ // ── Delete ───────────────────────────────────────────────────────
455
+ async delete(meta, uris) {
456
+ if (uris.length === 0)
457
+ return [];
458
+ if ((await this._readMetaSignature(meta.support.entity)) !== meta.signature) {
459
+ return uris.map((uri) => ({
460
+ success: false,
461
+ ...storageFailure(new Error(`${STORE_NAME}: entity '${meta.support.entity}' is not provisioned`), "Entity not provisioned", uri),
462
+ }));
463
+ }
168
464
  const results = [];
169
465
  for (const uri of uris) {
170
466
  try {
171
- const { index, docId } = uriToIndexAndDocId(uri, this.indexPrefix);
467
+ const { protocol, hostname, docId } = uriParts(uri);
468
+ const index = this._dataIndex(meta, protocol, hostname);
172
469
  await this.executor.delete(index, docId);
173
470
  results.push({ success: true });
174
471
  }
@@ -181,7 +478,7 @@ export class ElasticsearchStore {
181
478
  }
182
479
  return results;
183
480
  }
184
- // ── Status ───────────────────────────────────────────────────────
481
+ // ── Status / capabilities ────────────────────────────────────────
185
482
  async status() {
186
483
  try {
187
484
  const ok = await this.executor.ping();
@@ -189,13 +486,15 @@ export class ElasticsearchStore {
189
486
  return {
190
487
  status: "unhealthy",
191
488
  message: "Elasticsearch cluster is not reachable",
192
- fns: ["read", "ls", "count"],
489
+ fns: ["read", "ls", "count", "find"],
193
490
  };
194
491
  }
492
+ const schema = await this._listProvisionedEntities();
195
493
  return {
196
494
  status: "healthy",
197
495
  message: "Elasticsearch store is operational",
198
- fns: ["read", "ls", "count"],
496
+ schema: schema.map((s) => `entity:${s}`),
497
+ fns: ["read", "ls", "count", "find"],
199
498
  details: { indexPrefix: this.indexPrefix },
200
499
  };
201
500
  }
@@ -203,11 +502,71 @@ export class ElasticsearchStore {
203
502
  return {
204
503
  status: "unhealthy",
205
504
  message: error instanceof Error ? error.message : String(error),
206
- fns: ["read", "ls", "count"],
505
+ fns: ["read", "ls", "count", "find"],
207
506
  };
208
507
  }
209
508
  }
210
509
  capabilities() {
211
510
  return { atomicBatch: false };
212
511
  }
512
+ // ── Internals ────────────────────────────────────────────────────
513
+ /** Per-entity, per-`protocol_hostname` data index. */
514
+ _dataIndex(meta, protocol, hostname) {
515
+ return `${this.indexPrefix}_${meta.indexInfix}${protocol}_${hostname}`;
516
+ }
517
+ /** Single meta index holding `{name → signature}` provisioning records. */
518
+ _metaIndex() {
519
+ return `${this.indexPrefix}_${META_INDEX_SUFFIX}`;
520
+ }
521
+ /**
522
+ * Read the persisted signature for an entity, or `null` if no
523
+ * provisioning record exists.
524
+ */
525
+ async _readMetaSignature(entityName) {
526
+ try {
527
+ const doc = await this.executor.get(this._metaIndex(), entityName);
528
+ if (!doc)
529
+ return null;
530
+ return doc.signature ?? null;
531
+ }
532
+ catch {
533
+ return null;
534
+ }
535
+ }
536
+ /**
537
+ * Scan the meta index for every provisioned entity name. Used by
538
+ * `status()` to advertise the entities a peer can talk to.
539
+ */
540
+ async _listProvisionedEntities() {
541
+ try {
542
+ const result = await this.executor.search(this._metaIndex(), {
543
+ query: { match_all: {} },
544
+ size: 10_000,
545
+ });
546
+ return result.hits.map((h) => h._id);
547
+ }
548
+ catch {
549
+ return [];
550
+ }
551
+ }
552
+ /**
553
+ * Collect any `ReadableStream` values on `bytes`-tagged fields into
554
+ * `Uint8Array` and return a shallow-copied record.
555
+ */
556
+ async _normaliseBytesFields(record, bytesFields) {
557
+ const out = { ...record };
558
+ for (const name of bytesFields) {
559
+ const v = out[name];
560
+ if (v === undefined || v === null)
561
+ continue;
562
+ if (v instanceof Uint8Array)
563
+ continue;
564
+ if (v instanceof ReadableStream) {
565
+ out[name] = await toBytes(v);
566
+ continue;
567
+ }
568
+ throw new Error(`field '${name}' must be Uint8Array or ReadableStream, got ${typeof v}`);
569
+ }
570
+ return out;
571
+ }
213
572
  }
@@ -43,7 +43,7 @@
43
43
  * `fn=ls` and `fn=count` follow the same conventions as `Store`
44
44
  * (URI lists and numbers — see `./read.ts`).
45
45
  */
46
- import type { DeleteResult, Output, StatusResult } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js";
46
+ import type { DeleteResult, Output, StatusResult } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/types/types.js";
47
47
  import type { EntityMeta, EntityRecord, EntitySchema } from "./entity.js";
48
48
  import type { StoreCapabilities, StoreWriteResult } from "./types.js";
49
49
  export interface EntityStore<TMeta extends EntityMeta = EntityMeta> {
package/esm/errors.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * one line and the error code consistent (`STORAGE_ERROR` for
9
9
  * anything that came from the underlying driver).
10
10
  */
11
- import type { B3ndError } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js";
11
+ import type { B3ndError } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/types/types.js";
12
12
  /**
13
13
  * Build the structured-failure half of a Store result from a thrown
14
14
  * value. `fallback` is used when `err` isn't an `Error` (rare). `uri`