@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
@@ -27,6 +27,27 @@
27
27
  * predicate (`uri LIKE prefix% AND uri NOT LIKE prefix%/%`) is
28
28
  * enforced in the WHERE clause against the entity's table.
29
29
  *
30
+ * `fn=find` (v2 §3.5) push down to SQL too — same shape minus the
31
+ * `NOT LIKE %/%` shallow clause. With `pushDownFind: true` dispatch
32
+ * trusts the backend to combine the URI-embedded glob with pagination,
33
+ * cursor, and sort inside the query.
34
+ *
35
+ * `fn=count` honours deep (`**`) URLs by dropping the shallow clause
36
+ * when `parsed.glob.includes("**")`. With `pushDownCount: true`,
37
+ * dispatch routes every count shape — shallow and deep — to the
38
+ * backend's own `COUNT(*)` (faster than walking-and-counting through
39
+ * find).
40
+ *
41
+ * ## LIKE-injection safety
42
+ *
43
+ * Every value spliced into a SQL `LIKE` pattern via concatenation is
44
+ * pre-escaped with `escapeSqlLike()` (`%` → `\%`, `_` → `\_`,
45
+ * `\` → `\\`) and the surrounding clauses declare `ESCAPE '\\'`. This
46
+ * prevents a URI prefix or cursor containing `%` or `_` from matching
47
+ * siblings (e.g. `store://r/100%/` must NOT match `store://r/100x/...`
48
+ * under `fn=ls`). The glob body produced by `globToSqlLike` already
49
+ * encodes its own escaping for literal `%`/`_` in the pattern source.
50
+ *
30
51
  * Writes are wrapped in a transaction (`capabilities.atomicBatch =
31
52
  * true`). Stream-shaped values on `BYTEA` columns are collected to
32
53
  * `Uint8Array` *before* the transaction opens, so a stream failure
@@ -36,9 +57,10 @@ import { dispatchRead } from "../dispatch.js";
36
57
  import { storageFailure } from "../errors.js";
37
58
  import { toBytes } from "../payload.js";
38
59
  import { validateReadParams } from "../read.js";
60
+ import { globToSqlLike } from "../glob.js";
61
+ import { IDENTIFIER_PATTERN, isValidIdentifier } from "../identifiers.js";
39
62
  import { planColumns } from "./columns.js";
40
63
  const STORE_NAME = "PostgresStore";
41
- const TABLE_PREFIX = /^[a-zA-Z][a-zA-Z0-9_]*$/;
42
64
  /** Map a `ColumnPlan.sqlType` to the `information_schema.columns.data_type` value. */
43
65
  const INFO_SCHEMA_TYPE = {
44
66
  TEXT: "text",
@@ -63,9 +85,25 @@ function rowToBytes(value) {
63
85
  }
64
86
  throw new Error(`${STORE_NAME}: unexpected payload type ${typeof value}`);
65
87
  }
88
+ /**
89
+ * Escape a literal value for inclusion in a SQL `LIKE` pattern. The
90
+ * surrounding query MUST declare `ESCAPE '\\'` for this to apply. The
91
+ * three SQL-LIKE metacharacters are escaped:
92
+ * - `%` → `\%`
93
+ * - `_` → `\_`
94
+ * - `\` → `\\` (escape char itself)
95
+ *
96
+ * Without this, a URI prefix carrying `%` or `_` would broaden the
97
+ * LIKE match — e.g. `store://r/100%/` would match every sibling under
98
+ * `store://r/100x/...` (the `%` would act as wildcard inside the
99
+ * concatenated pattern `prefix || '%'`).
100
+ */
101
+ function escapeSqlLike(value) {
102
+ return value.replace(/[\\%_]/g, "\\$&");
103
+ }
66
104
  function entityTableName(tablePrefix, entityName) {
67
- if (!TABLE_PREFIX.test(entityName)) {
68
- throw new Error(`${STORE_NAME}: entity name '${entityName}' must match ${TABLE_PREFIX.source}`);
105
+ if (!isValidIdentifier(entityName)) {
106
+ throw new Error(`${STORE_NAME}: entity name '${entityName}' must match ${IDENTIFIER_PATTERN.source}`);
69
107
  }
70
108
  return `${tablePrefix}_${entityName}_data`;
71
109
  }
@@ -75,8 +113,8 @@ export class PostgresStore {
75
113
  constructor(tablePrefix, executor) {
76
114
  if (!tablePrefix)
77
115
  throw new Error("tablePrefix is required");
78
- if (!TABLE_PREFIX.test(tablePrefix)) {
79
- throw new Error(`tablePrefix must match ${TABLE_PREFIX.source}; got '${tablePrefix}'`);
116
+ if (!isValidIdentifier(tablePrefix)) {
117
+ throw new Error(`tablePrefix must match ${IDENTIFIER_PATTERN.source}; got '${tablePrefix}'`);
80
118
  }
81
119
  if (!executor)
82
120
  throw new Error("executor is required");
@@ -212,6 +250,12 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
212
250
  read: (p) => this._readOne(meta, p.uri),
213
251
  ls: (p) => this._ls(meta, p),
214
252
  count: (p) => this._count(meta, p),
253
+ find: (p) => this._find(meta, p),
254
+ pushDownPattern: true,
255
+ pushDownCursor: true,
256
+ pushDownSortBy: true,
257
+ pushDownFind: true,
258
+ pushDownCount: true,
215
259
  });
216
260
  }
217
261
  async delete(meta, uris) {
@@ -236,7 +280,7 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
236
280
  return {
237
281
  status: "healthy",
238
282
  message: "PostgreSQL store is operational",
239
- fns: ["read", "ls", "count"],
283
+ fns: ["read", "ls", "find", "count"],
240
284
  details: { tablePrefix: this.tablePrefix },
241
285
  };
242
286
  }
@@ -244,7 +288,7 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
244
288
  return {
245
289
  status: "unhealthy",
246
290
  message: `PostgreSQL health check failed: ${error instanceof Error ? error.message : String(error)}`,
247
- fns: ["read", "ls", "count"],
291
+ fns: ["read", "ls", "find", "count"],
248
292
  };
249
293
  }
250
294
  }
@@ -265,6 +309,31 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
265
309
  return adaptRowForRead(meta, res.rows[0]);
266
310
  }
267
311
  async _ls(meta, parsed) {
312
+ return await this._listQuery(meta, parsed, /*deep*/ false);
313
+ }
314
+ /**
315
+ * `fn=find` handler — deep walk under the prefix. Same query shape as
316
+ * `_ls` minus the `NOT LIKE prefix || '%/%'` shallow clause: every
317
+ * descendant URI matches the prefix; the URI-embedded glob (passed in
318
+ * via `params.pattern` by the parser) tightens the set with a SQL
319
+ * `LIKE` body. `pushDownFind: true` is declared so dispatch trusts the
320
+ * backend with pattern + pagination + sort.
321
+ */
322
+ async _find(meta, parsed) {
323
+ return await this._listQuery(meta, parsed, /*deep*/ true);
324
+ }
325
+ /**
326
+ * Shared SELECT for `_ls` and `_find`. The only structural difference
327
+ * is the shallow clause: `_ls` includes `uri NOT LIKE prefix || '%/%'
328
+ * ESCAPE '\\'`; `_find` drops it. Everything else — pattern body,
329
+ * cursor, sort, pagination — is identical.
330
+ *
331
+ * LIKE-injection safety: `parsed.uri` is pre-escaped with
332
+ * `escapeSqlLike()` before being bound. The bind value is the escaped
333
+ * prefix; the surrounding `LIKE $1 || '%' ESCAPE '\\'` then treats
334
+ * any `%`/`_` in the original prefix as literals.
335
+ */
336
+ async _listQuery(meta, parsed, deep) {
268
337
  validateReadParams(parsed.params, STORE_NAME);
269
338
  const { params } = parsed;
270
339
  const format = params.format ?? "full";
@@ -272,11 +341,43 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
272
341
  ? "uri, " + meta.columns.map((c) => `"${c.name}"`).join(", ")
273
342
  : "uri";
274
343
  const selectClause = format === "uris" ? "uri" : cols;
275
- const order = params.sortBy === "uri"
276
- ? ` ORDER BY uri ${params.sortOrder === "desc" ? "DESC" : "ASC"}`
344
+ // sortBy: "uri" sorts on the implicit URI column; any other value
345
+ // must be a declared user field, or dispatch will have already
346
+ // post-sorted and we should not double-sort here (dispatch strips
347
+ // non-uri sortBy from the handler call unless pushDownSortBy is
348
+ // true — which it is for this backend).
349
+ const sortByCol = params.sortBy === "uri"
350
+ ? "uri"
351
+ : (params.sortBy !== undefined && meta.declared.has(params.sortBy)
352
+ ? `"${params.sortBy}"`
353
+ : "");
354
+ const order = sortByCol
355
+ ? ` ORDER BY ${sortByCol} ${params.sortOrder === "desc" ? "DESC" : "ASC"}`
277
356
  : "";
278
- let sql = `SELECT ${selectClause} FROM ${meta.tableName} WHERE uri LIKE $1 || '%' AND uri NOT LIKE $1 || '%/%'${order}`;
279
- const args = [parsed.uri];
357
+ // `$1` is the escaped prefix; `ESCAPE '\\'` is declared on every
358
+ // LIKE clause that uses it so embedded `%`/`_` in the URI stay
359
+ // literal. The shallow `NOT LIKE prefix || '%/%'` clause is dropped
360
+ // for the find (deep) path. When `pattern` is set we tighten with
361
+ // an additional `LIKE prefix || patternBody` clause (the body is
362
+ // produced by `globToSqlLike` and already escapes its own
363
+ // metacharacters). When `cursor` is set we add `uri > cursor` (or
364
+ // `<` for desc) — cursor goes into a comparison op, not LIKE, so
365
+ // no escaping needed there.
366
+ let sql = `SELECT ${selectClause} FROM ${meta.tableName} WHERE uri LIKE $1 || '%' ESCAPE '\\'`;
367
+ if (!deep) {
368
+ sql += ` AND uri NOT LIKE $1 || '%/%' ESCAPE '\\'`;
369
+ }
370
+ const args = [escapeSqlLike(parsed.uri)];
371
+ if (params.pattern !== undefined) {
372
+ args.push(globToSqlLike(params.pattern));
373
+ sql += ` AND uri LIKE $1 || $${args.length} ESCAPE '\\'`;
374
+ }
375
+ if (params.cursor !== undefined) {
376
+ args.push(params.cursor);
377
+ const op = params.sortOrder === "desc" ? "<" : ">";
378
+ sql += ` AND uri ${op} $${args.length}`;
379
+ }
380
+ sql += order;
280
381
  if (params.limit !== undefined) {
281
382
  const page = params.page ?? 1;
282
383
  args.push(params.limit, (page - 1) * params.limit);
@@ -291,11 +392,30 @@ CREATE INDEX IF NOT EXISTS idx_${meta.tableName}_uri ON ${meta.tableName} (uri);
291
392
  adaptRowForRead(meta, r),
292
393
  ]);
293
394
  }
395
+ /**
396
+ * `fn=count` handler. Honours both shallow (`*`/`?` or no glob) and
397
+ * deep (`**`) URL shapes — the latter drops the `NOT LIKE %/%`
398
+ * predicate so descendants at any depth get counted. With
399
+ * `pushDownCount: true`, dispatch routes every count call through
400
+ * here (no walk-then-length fallback).
401
+ */
294
402
  async _count(meta, parsed) {
403
+ const deep = parsed.glob.includes("**");
404
+ let sql = `SELECT COUNT(*)::int AS n FROM ${meta.tableName} WHERE uri LIKE $1 || '%' ESCAPE '\\'`;
405
+ if (!deep) {
406
+ sql += ` AND uri NOT LIKE $1 || '%/%' ESCAPE '\\'`;
407
+ }
408
+ const args = [escapeSqlLike(parsed.uri)];
295
409
  if (parsed.params.pattern !== undefined) {
296
- throw new Error(`${STORE_NAME}: pattern filter not supported`);
410
+ args.push(globToSqlLike(parsed.params.pattern));
411
+ sql += ` AND uri LIKE $1 || $${args.length} ESCAPE '\\'`;
297
412
  }
298
- const res = await this.executor.query(`SELECT COUNT(*)::int AS n FROM ${meta.tableName} WHERE uri LIKE $1 || '%' AND uri NOT LIKE $1 || '%/%'`, [parsed.uri]);
413
+ if (parsed.params.cursor !== undefined) {
414
+ args.push(parsed.params.cursor);
415
+ const op = parsed.params.sortOrder === "desc" ? "<" : ">";
416
+ sql += ` AND uri ${op} $${args.length}`;
417
+ }
418
+ const res = await this.executor.query(sql, args);
299
419
  const row = res.rows?.[0];
300
420
  return row?.n ?? 0;
301
421
  }
package/esm/read.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * In-process post-processing for `fn=ls` results.
2
+ * In-process post-processing for `fn=ls`/`fn=find` results.
3
3
  *
4
4
  * Stores that cannot push `sortBy`/`limit`/`page`/`format` down to
5
5
  * their backend collect the raw `Output[]` rows under a prefix and
@@ -9,8 +9,15 @@
9
9
  *
10
10
  * Throws on unsupported params — programmer errors are not silent
11
11
  * "misses." See project decisions in `project_core_upgrade.md`.
12
+ *
13
+ * Glob compilation lives in `./glob.ts` — see `compileSaveGlob`,
14
+ * `globToSqlLike`, and `matchesGlob` for the regex/SQL adapters. This
15
+ * module no longer maintains its own glob grammar; the v2 listing
16
+ * spec (`.cc-chat/20260625121936-grammar-shape/output.md`) collapses
17
+ * read+observe onto one grammar — see §3.3.1 for the foundation-round
18
+ * wrapper resolution.
12
19
  */
13
- import type { Output } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js";
20
+ import type { Output } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/types/types.js";
14
21
  import type { ReadParams } from "./url.js";
15
22
  /**
16
23
  * Validate standard ReadParams and throw on anything we cannot honor.
@@ -20,10 +27,17 @@ import type { ReadParams } from "./url.js";
20
27
  * Stores that post-process in memory call `applyReadParams` instead,
21
28
  * which validates and applies in one go.
22
29
  *
23
- * Project-wide baseline: `pattern` and `cursor` are unsupported
24
- * everywhere; `sortBy` only accepts `"uri"`; `format` only accepts
25
- * `"full"` (default) or `"uris"`. Per-store relaxations should be
26
- * added explicitly as features land.
30
+ * Project-wide baseline:
31
+ * - `sortBy` accepts `"uri"` (push-down everywhere), `"leaf"` (basename
32
+ * `localeCompare`), or any record field name (dispatch-layer post-sort,
33
+ * or backend push-down where advertised)
34
+ * - `format` only accepts `"full"` (default) or `"uris"`
35
+ * - `pattern` is supported as a glob over the URI tail
36
+ * - `cursor` is supported as a stateless continuation token (the URI
37
+ * of the last entry from a previous page); combining with `page` is
38
+ * a programmer error since the two are alternative pagination modes
39
+ *
40
+ * Per-store relaxations should be added explicitly as features land.
27
41
  */
28
42
  export declare function validateReadParams(params: ReadParams, storeName: string): void;
29
43
  /**
@@ -34,7 +48,40 @@ export declare function validateReadParams(params: ReadParams, storeName: string
34
48
  * @param storeName label used in thrown error messages
35
49
  *
36
50
  * Returns `Output[]` when `format` is `"full"` (default) or `string[]`
37
- * when `format` is `"uris"`.
51
+ * when `format` is `"uris"`. When `params.fields` is set and `format`
52
+ * is `"full"`, each row's payload is projected via `projectRecord` —
53
+ * unknown projection field names are silently absent.
54
+ *
55
+ * `sortBy` values:
56
+ * - `"uri"` — `localeCompare` of the full URI
57
+ * - `"leaf"` — `localeCompare` of the basename
58
+ * (`uri.slice(uri.lastIndexOf("/") + 1)`)
59
+ * - any other string — treated as a record-field name; non-record
60
+ * payloads sort last (`undefined`)
38
61
  */
39
62
  export declare function applyReadParams<T>(rows: Output<T>[], params: ReadParams, storeName: string): Output<T>[] | string[];
63
+ /**
64
+ * Basename of a URI — everything after the last `/`. For URIs without a
65
+ * `/` (rare in save context but possible for opaque protocols) returns
66
+ * the whole string. Used by `sortBy=leaf` and exported for stores that
67
+ * push that sort down to a backend that needs a precomputed leaf
68
+ * column.
69
+ */
70
+ export declare function leafOf(uri: string): string;
71
+ /**
72
+ * Stable, type-aware comparator: nulls/undefineds sort last; numbers
73
+ * and bigints compare numerically; dates compare by `valueOf()`; the
74
+ * rest falls back to string-coerced `localeCompare` so the comparator
75
+ * never throws on heterogeneous field values.
76
+ */
77
+ export declare function compareSortable(a: unknown, b: unknown): number;
78
+ /**
79
+ * Project an `EntityRecord` down to the named fields. Unknown field
80
+ * names are silently absent — projection is a presentation directive,
81
+ * not a validation. Non-record values (eg `undefined` from a read
82
+ * miss, or a raw `Uint8Array` from a BYTES_ENTITY) pass through
83
+ * unchanged so callers can still detect misses and the BYTES path
84
+ * keeps its current shape.
85
+ */
86
+ export declare function projectRecord<T>(value: T, fields: readonly string[]): T;
40
87
  //# sourceMappingURL=read.d.ts.map
package/esm/read.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../src/read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kEAAkE,CAAC;AAC/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,IAAI,CAcN;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAC/B,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,EACjB,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,CAkBxB"}
1
+ {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../src/read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kEAAkE,CAAC;AAE/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,IAAI,CAUN;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAC/B,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,EACjB,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,CA8CxB;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG1C;AAgBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,MAAM,CAiB9D;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,CAAC,CAYvE"}
package/esm/read.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * In-process post-processing for `fn=ls` results.
2
+ * In-process post-processing for `fn=ls`/`fn=find` results.
3
3
  *
4
4
  * Stores that cannot push `sortBy`/`limit`/`page`/`format` down to
5
5
  * their backend collect the raw `Output[]` rows under a prefix and
@@ -9,6 +9,13 @@
9
9
  *
10
10
  * Throws on unsupported params — programmer errors are not silent
11
11
  * "misses." See project decisions in `project_core_upgrade.md`.
12
+ *
13
+ * Glob compilation lives in `./glob.ts` — see `compileSaveGlob`,
14
+ * `globToSqlLike`, and `matchesGlob` for the regex/SQL adapters. This
15
+ * module no longer maintains its own glob grammar; the v2 listing
16
+ * spec (`.cc-chat/20260625121936-grammar-shape/output.md`) collapses
17
+ * read+observe onto one grammar — see §3.3.1 for the foundation-round
18
+ * wrapper resolution.
12
19
  */
13
20
  /**
14
21
  * Validate standard ReadParams and throw on anything we cannot honor.
@@ -18,20 +25,21 @@
18
25
  * Stores that post-process in memory call `applyReadParams` instead,
19
26
  * which validates and applies in one go.
20
27
  *
21
- * Project-wide baseline: `pattern` and `cursor` are unsupported
22
- * everywhere; `sortBy` only accepts `"uri"`; `format` only accepts
23
- * `"full"` (default) or `"uris"`. Per-store relaxations should be
24
- * added explicitly as features land.
28
+ * Project-wide baseline:
29
+ * - `sortBy` accepts `"uri"` (push-down everywhere), `"leaf"` (basename
30
+ * `localeCompare`), or any record field name (dispatch-layer post-sort,
31
+ * or backend push-down where advertised)
32
+ * - `format` only accepts `"full"` (default) or `"uris"`
33
+ * - `pattern` is supported as a glob over the URI tail
34
+ * - `cursor` is supported as a stateless continuation token (the URI
35
+ * of the last entry from a previous page); combining with `page` is
36
+ * a programmer error since the two are alternative pagination modes
37
+ *
38
+ * Per-store relaxations should be added explicitly as features land.
25
39
  */
26
40
  export function validateReadParams(params, storeName) {
27
- if (params.pattern !== undefined) {
28
- throw new Error(`${storeName}: pattern filter not supported`);
29
- }
30
- if (params.cursor !== undefined) {
31
- throw new Error(`${storeName}: cursor not supported`);
32
- }
33
- if (params.sortBy !== undefined && params.sortBy !== "uri") {
34
- throw new Error(`${storeName}: unsupported sortBy: ${params.sortBy}`);
41
+ if (params.cursor !== undefined && params.page !== undefined) {
42
+ throw new Error(`${storeName}: cursor and page cannot be combined — pick one pagination mode`);
35
43
  }
36
44
  const format = params.format ?? "full";
37
45
  if (format !== "full" && format !== "uris") {
@@ -46,15 +54,42 @@ export function validateReadParams(params, storeName) {
46
54
  * @param storeName label used in thrown error messages
47
55
  *
48
56
  * Returns `Output[]` when `format` is `"full"` (default) or `string[]`
49
- * when `format` is `"uris"`.
57
+ * when `format` is `"uris"`. When `params.fields` is set and `format`
58
+ * is `"full"`, each row's payload is projected via `projectRecord` —
59
+ * unknown projection field names are silently absent.
60
+ *
61
+ * `sortBy` values:
62
+ * - `"uri"` — `localeCompare` of the full URI
63
+ * - `"leaf"` — `localeCompare` of the basename
64
+ * (`uri.slice(uri.lastIndexOf("/") + 1)`)
65
+ * - any other string — treated as a record-field name; non-record
66
+ * payloads sort last (`undefined`)
50
67
  */
51
68
  export function applyReadParams(rows, params, storeName) {
52
69
  validateReadParams(params, storeName);
53
70
  const format = params.format ?? "full";
54
71
  let out = rows;
55
- if (params.sortBy === "uri") {
72
+ if (params.sortBy !== undefined) {
56
73
  const dir = params.sortOrder === "desc" ? -1 : 1;
57
- out = [...out].sort(([a], [b]) => a.localeCompare(b) * dir);
74
+ if (params.sortBy === "uri") {
75
+ out = [...out].sort(([a], [b]) => a.localeCompare(b) * dir);
76
+ }
77
+ else if (params.sortBy === "leaf") {
78
+ out = [...out].sort(([a], [b]) => leafOf(a).localeCompare(leafOf(b)) * dir);
79
+ }
80
+ else {
81
+ const field = params.sortBy;
82
+ out = [...out].sort(([, a], [, b]) => {
83
+ const av = recordField(a, field);
84
+ const bv = recordField(b, field);
85
+ return compareSortable(av, bv) * dir;
86
+ });
87
+ }
88
+ }
89
+ if (params.cursor !== undefined) {
90
+ const cursor = params.cursor;
91
+ const desc = params.sortOrder === "desc";
92
+ out = out.filter(([uri]) => desc ? uri.localeCompare(cursor) < 0 : uri.localeCompare(cursor) > 0);
58
93
  }
59
94
  if (params.limit !== undefined) {
60
95
  const page = params.page ?? 1;
@@ -63,5 +98,95 @@ export function applyReadParams(rows, params, storeName) {
63
98
  }
64
99
  if (format === "uris")
65
100
  return out.map(([uri]) => uri);
101
+ if (params.fields && params.fields.length > 0) {
102
+ const allow = params.fields;
103
+ return out.map(([uri, payload]) => [
104
+ uri,
105
+ projectRecord(payload, allow),
106
+ ]);
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * Basename of a URI — everything after the last `/`. For URIs without a
112
+ * `/` (rare in save context but possible for opaque protocols) returns
113
+ * the whole string. Used by `sortBy=leaf` and exported for stores that
114
+ * push that sort down to a backend that needs a precomputed leaf
115
+ * column.
116
+ */
117
+ export function leafOf(uri) {
118
+ const idx = uri.lastIndexOf("/");
119
+ return idx < 0 ? uri : uri.slice(idx + 1);
120
+ }
121
+ /**
122
+ * Read a named field from a payload, returning `undefined` for any
123
+ * non-record payload (eg `Uint8Array`, `ReadableStream`, miss). Used
124
+ * by the in-memory sort path so non-uri sortBy works against
125
+ * `EntityRecord` rows.
126
+ */
127
+ function recordField(value, field) {
128
+ if (value === undefined || value === null)
129
+ return undefined;
130
+ if (typeof value !== "object")
131
+ return undefined;
132
+ if (value instanceof Uint8Array)
133
+ return undefined;
134
+ if (value instanceof ReadableStream)
135
+ return undefined;
136
+ return value[field];
137
+ }
138
+ /**
139
+ * Stable, type-aware comparator: nulls/undefineds sort last; numbers
140
+ * and bigints compare numerically; dates compare by `valueOf()`; the
141
+ * rest falls back to string-coerced `localeCompare` so the comparator
142
+ * never throws on heterogeneous field values.
143
+ */
144
+ export function compareSortable(a, b) {
145
+ const aMissing = a === undefined || a === null;
146
+ const bMissing = b === undefined || b === null;
147
+ if (aMissing && bMissing)
148
+ return 0;
149
+ if (aMissing)
150
+ return 1;
151
+ if (bMissing)
152
+ return -1;
153
+ if (typeof a === "number" && typeof b === "number")
154
+ return a - b;
155
+ if (typeof a === "bigint" && typeof b === "bigint") {
156
+ return a < b ? -1 : a > b ? 1 : 0;
157
+ }
158
+ if (a instanceof Date && b instanceof Date) {
159
+ return a.valueOf() - b.valueOf();
160
+ }
161
+ if (typeof a === "boolean" && typeof b === "boolean") {
162
+ return (a ? 1 : 0) - (b ? 1 : 0);
163
+ }
164
+ return String(a).localeCompare(String(b));
165
+ }
166
+ /**
167
+ * Project an `EntityRecord` down to the named fields. Unknown field
168
+ * names are silently absent — projection is a presentation directive,
169
+ * not a validation. Non-record values (eg `undefined` from a read
170
+ * miss, or a raw `Uint8Array` from a BYTES_ENTITY) pass through
171
+ * unchanged so callers can still detect misses and the BYTES path
172
+ * keeps its current shape.
173
+ */
174
+ export function projectRecord(value, fields) {
175
+ if (value === undefined || value === null)
176
+ return value;
177
+ if (typeof value !== "object")
178
+ return value;
179
+ if (value instanceof Uint8Array)
180
+ return value;
181
+ if (value instanceof ReadableStream)
182
+ return value;
183
+ if (Array.isArray(value))
184
+ return value;
185
+ const out = {};
186
+ const src = value;
187
+ for (const f of fields) {
188
+ if (Object.prototype.hasOwnProperty.call(src, f))
189
+ out[f] = src[f];
190
+ }
66
191
  return out;
67
192
  }