@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,20 +1,71 @@
1
1
  /**
2
- * SqliteStore — SQLite implementation of Store.
2
+ * SqliteStore — SQLite implementation of `EntityStore`.
3
3
  *
4
- * Pure mechanical byte storage with no protocol awareness. Pushes
5
- * fn=ls/fn=count down to SQL using the shallow-direct-leaves
6
- * predicate: `uri LIKE ? || '%' AND uri NOT LIKE ? || '%/%'`.
4
+ * One layout for every schema: `{prefix}_{entity}_data` with `uri TEXT
5
+ * PRIMARY KEY` and one column per supported field, typed by the
6
+ * canonical `TYPE_TAGS` it carries (see `./columns.ts`). `BYTES_ENTITY`
7
+ * is just an entity with one `payload BLOB` column — no legacy
8
+ * special case.
7
9
  *
8
- * Uses an injected synchronous SqliteExecutor; results are wrapped in
9
- * Promise.resolve() to satisfy the async Store interface. `payload`
10
- * is a BLOB; the store does not inspect or transform its contents.
10
+ * ## Lifecycle
11
+ *
12
+ * `entitySupport(schema)` is pure it runs `planColumns` and returns
13
+ * a `SqliteEntityMeta` with the target table name, column plans,
14
+ * bytes-column set, and the support report. No IO.
15
+ *
16
+ * `provisionEntity(meta)` runs `CREATE TABLE IF NOT EXISTS` and then
17
+ * verifies the live shape matches the meta (catches same-name-
18
+ * different-shape collisions). `entityStatus(meta)` performs the same
19
+ * check in isolation via `PRAGMA table_info`.
20
+ *
21
+ * Writes/reads/deletes consume the meta directly: no per-call cache
22
+ * gate. Operations against a not-yet-provisioned meta surface
23
+ * SQLite's natural failure per entry.
24
+ *
25
+ * `fn=ls` / `fn=count` push down to SQL — the shallow-direct-leaves
26
+ * predicate (`uri LIKE prefix || '%' AND uri NOT LIKE prefix || '%/%'`)
27
+ * is enforced in the WHERE clause against the entity's table.
28
+ *
29
+ * `fn=find` (v2 spec §3.5; foundation PR #82) reuses the same `_ls`
30
+ * SQL composition with a single branch: under `fn=find` the shallow
31
+ * safety predicate (`AND uri NOT LIKE prefix || '%/%'`) is DROPPED so
32
+ * the LIKE prefix walk crosses `/` and matches the whole subtree.
33
+ * The prefix bind is still a two-arg `[prefix, globToSqlLike(pattern)]`
34
+ * pair — never concatenated into the pattern body, so room/segment
35
+ * names containing literal `%`/`_` cannot break out of the parameter
36
+ * (LIKE-injection safety via `ESCAPE '\'`). `pushDownFind: true` is
37
+ * advertised so dispatch trusts the backend's deep walk.
38
+ *
39
+ * Writes are wrapped in a transaction (`capabilities.atomicBatch =
40
+ * true`). Stream-shaped values on `BLOB` columns are collected to
41
+ * `Uint8Array` *before* the transaction opens.
11
42
  */
12
- import { bytesOnlyDelete, bytesOnlyEntitySupport, bytesOnlyRead, bytesOnlyWrite, } from "../byte-entity-shim.js";
13
43
  import { dispatchRead } from "../dispatch.js";
14
44
  import { storageFailure } from "../errors.js";
15
45
  import { toBytes } from "../payload.js";
16
46
  import { validateReadParams } from "../read.js";
47
+ import { globToSqlLike } from "../glob.js";
48
+ import { TYPE_TAGS, } from "../entity.js";
49
+ import { IDENTIFIER_PATTERN, isValidIdentifier } from "../identifiers.js";
50
+ import { planColumns } from "./columns.js";
17
51
  const STORE_NAME = "SqliteStore";
52
+ /**
53
+ * Escape the SQL `LIKE` metacharacters (`%`, `_`, `\`) in a literal
54
+ * prefix or substring so the value can be safely bound into a `LIKE ?`
55
+ * clause paired with `ESCAPE '\'`. Without this, a prefix carrying a
56
+ * literal `%` (URL-encoded byte segment) or `_` (snake_case segment)
57
+ * would expand to a SQL wildcard inside the bind value — a
58
+ * LIKE-injection that parameterisation alone does NOT block.
59
+ *
60
+ * Used by `_ls` and `_count` for every prefix bind so room/segment
61
+ * names containing `%`/`_` stay inert.
62
+ */
63
+ function escapeSqlLike(value) {
64
+ return value
65
+ .replace(/\\/g, "\\\\")
66
+ .replace(/%/g, "\\%")
67
+ .replace(/_/g, "\\_");
68
+ }
18
69
  function rowToBytes(value) {
19
70
  if (value instanceof Uint8Array)
20
71
  return value;
@@ -22,129 +73,168 @@ function rowToBytes(value) {
22
73
  return new Uint8Array(value);
23
74
  if (Array.isArray(value))
24
75
  return new Uint8Array(value);
25
- throw new Error(`SqliteStore: unexpected payload type ${typeof value}`);
76
+ throw new Error(`${STORE_NAME}: unexpected payload type ${typeof value}`);
77
+ }
78
+ function entityTableName(tablePrefix, entityName) {
79
+ if (!isValidIdentifier(entityName)) {
80
+ throw new Error(`${STORE_NAME}: entity name '${entityName}' must match ${IDENTIFIER_PATTERN.source}`);
81
+ }
82
+ return `${tablePrefix}_${entityName}_data`;
26
83
  }
27
84
  export class SqliteStore {
28
- tableName;
85
+ tablePrefix;
29
86
  executor;
30
- // Names of entities provisioned on this store. SqliteStore currently
31
- // routes only BYTES_ENTITY, so this is at most `{"bytes"}` — but the
32
- // tracking shape matches MemoryStore so `status().schema` reads the
33
- // same way across backends.
34
- provisioned = new Set();
35
87
  constructor(tablePrefix, executor) {
36
88
  if (!tablePrefix)
37
89
  throw new Error("tablePrefix is required");
90
+ if (!isValidIdentifier(tablePrefix)) {
91
+ throw new Error(`tablePrefix must match ${IDENTIFIER_PATTERN.source}; got '${tablePrefix}'`);
92
+ }
38
93
  if (!executor)
39
94
  throw new Error("executor is required");
40
- this.tableName = `${tablePrefix}_data`;
95
+ this.tablePrefix = tablePrefix;
41
96
  this.executor = executor;
42
97
  }
43
- // ── EntityStore surface ──────────────────────────────────────────
98
+ // ── Lifecycle ────────────────────────────────────────────────────
44
99
  entitySupport(schema) {
45
- return bytesOnlyEntitySupport(schema);
46
- }
47
- entityStatus(meta) {
48
- return Promise.resolve(meta.isBytes ? "live" : "unprovisioned");
49
- }
50
- provisionEntity(meta) {
51
- // Only BYTES_ENTITY is honored at the storage layer; record the
52
- // name so `status()` surfaces what callers can actually use.
53
- // Non-bytes schemas are silently ignored — `write`/`read` already
54
- // surface "Unsupported schema" via the shim.
55
- if (meta.isBytes)
56
- this.provisioned.add(meta.schema.name);
57
- return Promise.resolve();
100
+ const { columns, unsupported } = planColumns(schema.fields);
101
+ const support = {
102
+ entity: schema.name,
103
+ supported: columns.map((c) => c.name),
104
+ unsupported,
105
+ };
106
+ return {
107
+ support,
108
+ tableName: entityTableName(this.tablePrefix, schema.name),
109
+ columns,
110
+ declared: new Set(columns.map((c) => c.name)),
111
+ bytesColumns: new Set(columns.filter((c) => c.sqlType === "BLOB").map((c) => c.name)),
112
+ };
58
113
  }
59
- write(meta, entries) {
60
- return bytesOnlyWrite(meta, STORE_NAME, entries, (e) => this._writeBytes(e));
61
- }
62
- read(meta, urls) {
63
- return bytesOnlyRead(meta, STORE_NAME, urls, (u) => this._readBytes(u));
114
+ // deno-lint-ignore require-await
115
+ async entityStatus(meta) {
116
+ const info = this.executor.query(`PRAGMA table_info("${meta.tableName}")`);
117
+ const rows = (info.rows ?? []);
118
+ if (rows.length === 0)
119
+ return "unprovisioned";
120
+ const actual = new Map(rows.map((r) => [r.name, (r.type ?? "").toUpperCase()]));
121
+ for (const c of meta.columns) {
122
+ const got = actual.get(c.name);
123
+ if (got === undefined)
124
+ return "unprovisioned";
125
+ if (got !== c.sqlType)
126
+ return "unprovisioned";
127
+ }
128
+ // Reject collisions in the other direction: extra user columns
129
+ // present in the live table that the meta doesn't declare are a
130
+ // different shape.
131
+ for (const name of actual.keys()) {
132
+ if (name === "uri" || name === "created_at" || name === "updated_at") {
133
+ continue;
134
+ }
135
+ if (!meta.declared.has(name))
136
+ return "unprovisioned";
137
+ }
138
+ return "live";
64
139
  }
65
- delete(meta, uris) {
66
- return bytesOnlyDelete(meta, STORE_NAME, uris, (u) => this._deleteBytes(u));
140
+ async provisionEntity(meta) {
141
+ const colDdl = meta.columns
142
+ .map((c) => `"${c.name}" ${c.sqlType}`)
143
+ .join(",\n ");
144
+ const sql = `CREATE TABLE IF NOT EXISTS "${meta.tableName}" (
145
+ uri TEXT PRIMARY KEY${meta.columns.length > 0 ? ",\n " + colDdl : ""},
146
+ created_at TEXT DEFAULT (datetime('now')),
147
+ updated_at TEXT DEFAULT (datetime('now'))
148
+ );
149
+ CREATE INDEX IF NOT EXISTS "idx_${meta.tableName}_uri" ON "${meta.tableName}" (uri);`;
150
+ // SQLite drivers vary on whether `query` accepts multi-statement
151
+ // SQL; split and run each.
152
+ for (const stmt of sql.split(";").map((s) => s.trim()).filter(Boolean)) {
153
+ this.executor.query(stmt);
154
+ }
155
+ const status = await this.entityStatus(meta);
156
+ if (status !== "live") {
157
+ throw new Error(`${STORE_NAME}: entity '${meta.support.entity}' is already ` +
158
+ `provisioned with a different shape at table '${meta.tableName}'`);
159
+ }
67
160
  }
68
- // ── Byte ops (BYTES_ENTITY routing) ──────────────────────────────
69
- async _writeBytes(entries) {
161
+ // ── Write ────────────────────────────────────────────────────────
162
+ async write(meta, entries) {
70
163
  if (entries.length === 0)
71
164
  return [];
72
- // Collect streams up front: the executor is synchronous (BLOB
73
- // wants bytes), and we don't want to await mid-transaction.
74
- const prepared = await Promise.all(entries.map(async (e) => ({
75
- uri: e.uri,
76
- bytes: await toBytes(e.payload),
77
- })));
78
- // `capabilities.atomicBatch` is true: the whole batch commits or
79
- // nothing does. On failure every result is `{ success: false }`
80
- // with the same root-cause error.
165
+ const out = new Array(entries.length);
166
+ const accepted = [];
167
+ for (let i = 0; i < entries.length; i++) {
168
+ const { uri, record } = entries[i];
169
+ const extras = Object.keys(record).filter((k) => !meta.declared.has(k));
170
+ if (extras.length > 0) {
171
+ out[i] = {
172
+ success: false,
173
+ ...storageFailure(new Error(`${STORE_NAME}: record contains keys not declared in schema '${meta.support.entity}': ${extras.join(", ")}`), "Schema mismatch", uri),
174
+ };
175
+ continue;
176
+ }
177
+ try {
178
+ const normalised = await normaliseBytesFields(record, meta.bytesColumns);
179
+ accepted.push({ idx: i, uri, record: normalised });
180
+ }
181
+ catch (err) {
182
+ out[i] = {
183
+ success: false,
184
+ ...storageFailure(err, "Invalid record", uri),
185
+ };
186
+ }
187
+ }
188
+ if (accepted.length === 0)
189
+ return out;
190
+ const cols = meta.columns;
191
+ const colList = cols.map((c) => `"${c.name}"`).join(", ");
192
+ const placeholders = cols.map(() => "?").join(", ");
193
+ const updates = cols
194
+ .map((c) => `"${c.name}" = excluded."${c.name}"`)
195
+ .concat(["updated_at = datetime('now')"])
196
+ .join(", ");
81
197
  try {
82
198
  this.executor.transaction((tx) => {
83
- for (const entry of prepared) {
84
- tx.query(`INSERT INTO ${this.tableName} (uri, payload, updated_at)
85
- VALUES (?, ?, datetime('now'))
86
- ON CONFLICT(uri) DO UPDATE SET
87
- payload = excluded.payload,
88
- updated_at = datetime('now')`, [entry.uri, entry.bytes]);
199
+ for (const { uri, record } of accepted) {
200
+ const args = [uri];
201
+ for (const c of cols)
202
+ args.push(adaptForWrite(c, record[c.name]));
203
+ tx.query(`INSERT INTO "${meta.tableName}" (uri${colList ? ", " + colList : ""}) VALUES (?${placeholders ? ", " + placeholders : ""})
204
+ ON CONFLICT(uri) DO UPDATE SET ${updates}`, args);
89
205
  }
90
206
  });
91
- return entries.map(() => ({ success: true }));
207
+ for (const { idx } of accepted)
208
+ out[idx] = { success: true };
209
+ return out;
92
210
  }
93
211
  catch (err) {
94
212
  const failure = storageFailure(err, "Write failed");
95
- return entries.map(() => ({ success: false, ...failure }));
213
+ for (const { idx } of accepted)
214
+ out[idx] = { success: false, ...failure };
215
+ return out;
96
216
  }
97
217
  }
98
- _readBytes(urls) {
218
+ // ── Read ─────────────────────────────────────────────────────────
219
+ read(meta, urls) {
99
220
  return dispatchRead(urls, STORE_NAME, {
100
- read: (p) => this._readOne(p.uri),
101
- ls: (p) => this._ls(p),
102
- count: (p) => this._count(p),
221
+ read: (p) => Promise.resolve(this._readOne(meta, p.uri)),
222
+ ls: (p) => Promise.resolve(this._ls(meta, p, false)),
223
+ find: (p) => Promise.resolve(this._ls(meta, p, true)),
224
+ count: (p) => Promise.resolve(this._count(meta, p)),
225
+ pushDownPattern: true,
226
+ pushDownCursor: true,
227
+ pushDownSortBy: true,
228
+ pushDownFind: true,
103
229
  });
104
230
  }
105
- _readOne(uri) {
106
- const result = this.executor.query(`SELECT payload FROM ${this.tableName} WHERE uri = ?`, [uri]);
107
- if (!result.rows.length)
108
- return undefined;
109
- return rowToBytes(result.rows[0].payload);
110
- }
111
- _ls(parsed) {
112
- validateReadParams(parsed.params, STORE_NAME);
113
- const { params } = parsed;
114
- const format = params.format ?? "full";
115
- const select = format === "uris" ? "uri" : "uri, payload";
116
- const order = params.sortBy === "uri"
117
- ? ` ORDER BY uri ${params.sortOrder === "desc" ? "DESC" : "ASC"}`
118
- : "";
119
- let sql = `SELECT ${select} FROM ${this.tableName} WHERE uri LIKE ? || '%' AND uri NOT LIKE ? || '%/%'${order}`;
120
- const args = [parsed.uri, parsed.uri];
121
- if (params.limit !== undefined) {
122
- const page = params.page ?? 1;
123
- sql += ` LIMIT ? OFFSET ?`;
124
- args.push(params.limit, (page - 1) * params.limit);
125
- }
126
- const result = this.executor.query(sql, args);
127
- const rows = result.rows;
128
- if (format === "uris")
129
- return rows.map((r) => r.uri);
130
- return rows.map((r) => [r.uri, rowToBytes(r.payload)]);
131
- }
132
- _count(parsed) {
133
- if (parsed.params.pattern !== undefined) {
134
- throw new Error(`${STORE_NAME}: pattern filter not supported`);
135
- }
136
- const result = this.executor.query(`SELECT COUNT(*) AS n FROM ${this.tableName} WHERE uri LIKE ? || '%' AND uri NOT LIKE ? || '%/%'`, [parsed.uri, parsed.uri]);
137
- const row = result.rows[0];
138
- return row?.n ?? 0;
139
- }
140
- _deleteBytes(uris) {
231
+ delete(meta, uris) {
141
232
  if (uris.length === 0)
142
233
  return Promise.resolve([]);
143
- // Atomic batch: every uri either deletes together or not at all.
144
234
  try {
145
235
  this.executor.transaction((tx) => {
146
236
  for (const uri of uris) {
147
- tx.query(`DELETE FROM ${this.tableName} WHERE uri = ?`, [uri]);
237
+ tx.query(`DELETE FROM "${meta.tableName}" WHERE uri = ?`, [uri]);
148
238
  }
149
239
  });
150
240
  return Promise.resolve(uris.map(() => ({ success: true })));
@@ -154,31 +244,245 @@ export class SqliteStore {
154
244
  return Promise.resolve(uris.map(() => ({ success: false, ...failure })));
155
245
  }
156
246
  }
157
- // ── Status ───────────────────────────────────────────────────────
247
+ // ── Status / capabilities ────────────────────────────────────────
158
248
  status() {
159
249
  try {
160
250
  this.executor.query("SELECT 1");
161
251
  return Promise.resolve({
162
252
  status: "healthy",
163
253
  message: "SQLite store is operational",
164
- schema: this.schemaUris(),
165
- fns: ["read", "ls", "count"],
166
- details: { tableName: this.tableName },
254
+ schema: this._listProvisionedEntities().map((n) => `entity:${n}`),
255
+ fns: ["read", "ls", "find", "count"],
256
+ details: { tablePrefix: this.tablePrefix },
167
257
  });
168
258
  }
169
259
  catch (error) {
170
260
  return Promise.resolve({
171
261
  status: "unhealthy",
172
262
  message: error instanceof Error ? error.message : String(error),
173
- schema: this.schemaUris(),
174
- fns: ["read", "ls", "count"],
263
+ schema: [],
264
+ fns: ["read", "ls", "find", "count"],
175
265
  });
176
266
  }
177
267
  }
178
- schemaUris() {
179
- return [...this.provisioned].map((name) => `entity:${name}`);
180
- }
181
268
  capabilities() {
182
269
  return { atomicBatch: true };
183
270
  }
271
+ // ── Internals ────────────────────────────────────────────────────
272
+ _readOne(meta, uri) {
273
+ try {
274
+ if (meta.columns.length === 0) {
275
+ // Empty schema: presence-only — return an empty record on hit.
276
+ const res = this.executor.query(`SELECT 1 FROM "${meta.tableName}" WHERE uri = ?`, [uri]);
277
+ return res.rows && res.rows.length > 0 ? {} : undefined;
278
+ }
279
+ const cols = meta.columns.map((c) => `"${c.name}"`).join(", ");
280
+ const res = this.executor.query(`SELECT ${cols} FROM "${meta.tableName}" WHERE uri = ?`, [uri]);
281
+ if (!res.rows || res.rows.length === 0)
282
+ return undefined;
283
+ return adaptRowForRead(meta, res.rows[0]);
284
+ }
285
+ catch {
286
+ // `no such table` and other natural read failures surface as
287
+ // misses — matches the convention that unprovisioned entities
288
+ // read empty rather than throwing.
289
+ return undefined;
290
+ }
291
+ }
292
+ _ls(meta, parsed, deep) {
293
+ validateReadParams(parsed.params, STORE_NAME);
294
+ const { params } = parsed;
295
+ const format = params.format ?? "full";
296
+ const cols = meta.columns.length > 0
297
+ ? "uri, " + meta.columns.map((c) => `"${c.name}"`).join(", ")
298
+ : "uri";
299
+ const selectClause = format === "uris" ? "uri" : cols;
300
+ // sortBy: "uri" sorts on the implicit URI column; any other value
301
+ // must be a declared user field (validated against meta.declared
302
+ // to keep the column name out of arbitrary user input).
303
+ const sortByCol = params.sortBy === "uri"
304
+ ? "uri"
305
+ : (params.sortBy !== undefined && meta.declared.has(params.sortBy)
306
+ ? `"${params.sortBy}"`
307
+ : "");
308
+ const order = sortByCol
309
+ ? ` ORDER BY ${sortByCol} ${params.sortOrder === "desc" ? "DESC" : "ASC"}`
310
+ : "";
311
+ // Base prefix predicate. Under `fn=ls` (deep=false) the
312
+ // shallow-direct-leaves safety predicate `AND uri NOT LIKE
313
+ // prefix||'%/%'` is layered on top so the listing stops at depth-1.
314
+ // Under `fn=find` (deep=true) that predicate is dropped — find's
315
+ // entire purpose is to cross `/` and walk the subtree.
316
+ //
317
+ // LIKE-injection safety: SQLite parameterisation prevents SQL
318
+ // injection but the `%`/`_` characters carried in the bind VALUE
319
+ // are still treated as wildcards by LIKE. Room and segment names
320
+ // routinely include `%` (URL-encoded bytes) or `_` (snake_case),
321
+ // so we ESCAPE the prefix bind with `\` and pair every LIKE clause
322
+ // with `ESCAPE '\'`. Without the escape, a prefix
323
+ // `store://r/100%/` would match `store://r/100x/...` too. The
324
+ // pattern body is also escaped (by `globToSqlLike`) and uses the
325
+ // same `ESCAPE '\'`.
326
+ const prefixLike = escapeSqlLike(parsed.uri);
327
+ let sql = `SELECT ${selectClause} FROM "${meta.tableName}" WHERE uri LIKE ? || '%' ESCAPE '\\'`;
328
+ const args = [prefixLike];
329
+ if (!deep) {
330
+ sql += ` AND uri NOT LIKE ? || '%/%' ESCAPE '\\'`;
331
+ args.push(prefixLike);
332
+ }
333
+ if (params.pattern !== undefined) {
334
+ args.push(prefixLike, globToSqlLike(params.pattern));
335
+ sql += ` AND uri LIKE ? || ? ESCAPE '\\'`;
336
+ }
337
+ if (params.cursor !== undefined) {
338
+ args.push(params.cursor);
339
+ const op = params.sortOrder === "desc" ? "<" : ">";
340
+ sql += ` AND uri ${op} ?`;
341
+ }
342
+ sql += order;
343
+ if (params.limit !== undefined) {
344
+ const page = params.page ?? 1;
345
+ sql += ` LIMIT ? OFFSET ?`;
346
+ args.push(params.limit, (page - 1) * params.limit);
347
+ }
348
+ try {
349
+ const res = this.executor.query(sql, args);
350
+ const rows = (res.rows ?? []);
351
+ if (format === "uris")
352
+ return rows.map((r) => r.uri);
353
+ return rows.map((r) => [
354
+ r.uri,
355
+ adaptRowForRead(meta, r),
356
+ ]);
357
+ }
358
+ catch {
359
+ return format === "uris" ? [] : [];
360
+ }
361
+ }
362
+ _count(meta, parsed) {
363
+ // Same LIKE-injection treatment as `_ls`: escape the prefix bind
364
+ // and pair every LIKE with `ESCAPE '\'` so literal `%`/`_` in
365
+ // room/segment names stay inert.
366
+ const prefixLike = escapeSqlLike(parsed.uri);
367
+ let sql = `SELECT COUNT(*) AS n FROM "${meta.tableName}" WHERE uri LIKE ? || '%' ESCAPE '\\' AND uri NOT LIKE ? || '%/%' ESCAPE '\\'`;
368
+ const args = [prefixLike, prefixLike];
369
+ if (parsed.params.pattern !== undefined) {
370
+ args.push(prefixLike, globToSqlLike(parsed.params.pattern));
371
+ sql += ` AND uri LIKE ? || ? ESCAPE '\\'`;
372
+ }
373
+ if (parsed.params.cursor !== undefined) {
374
+ args.push(parsed.params.cursor);
375
+ const op = parsed.params.sortOrder === "desc" ? "<" : ">";
376
+ sql += ` AND uri ${op} ?`;
377
+ }
378
+ try {
379
+ const res = this.executor.query(sql, args);
380
+ const row = res.rows?.[0];
381
+ return row?.n ?? 0;
382
+ }
383
+ catch {
384
+ return 0;
385
+ }
386
+ }
387
+ /**
388
+ * Enumerate every `{tablePrefix}_*_data` table currently in the
389
+ * database. Used by `status()` to advertise the entities a peer can
390
+ * talk to without per-entity bookkeeping.
391
+ */
392
+ _listProvisionedEntities() {
393
+ const res = this.executor.query(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? || '\\_%\\_data' ESCAPE '\\'`, [this.tablePrefix]);
394
+ const out = [];
395
+ for (const row of res.rows ?? []) {
396
+ const name = row.name;
397
+ const suffix = "_data";
398
+ const prefixWithU = `${this.tablePrefix}_`;
399
+ if (!name.startsWith(prefixWithU) || !name.endsWith(suffix))
400
+ continue;
401
+ out.push(name.slice(prefixWithU.length, name.length - suffix.length));
402
+ }
403
+ return out;
404
+ }
405
+ }
406
+ // ── Adapters ────────────────────────────────────────────────────────
407
+ /**
408
+ * Collect any `ReadableStream` values on `BLOB` columns into
409
+ * `Uint8Array` and return a shallow-copied record. Runs before the
410
+ * write transaction so stream failures can't leave a half-applied
411
+ * commit. Non-stream values pass through verbatim.
412
+ */
413
+ async function normaliseBytesFields(record, bytesColumns) {
414
+ if (bytesColumns.size === 0)
415
+ return record;
416
+ const out = { ...record };
417
+ for (const name of bytesColumns) {
418
+ const v = out[name];
419
+ if (v === undefined || v === null)
420
+ continue;
421
+ if (v instanceof Uint8Array)
422
+ continue;
423
+ if (v instanceof ReadableStream) {
424
+ out[name] = await toBytes(v);
425
+ continue;
426
+ }
427
+ throw new Error(`${STORE_NAME}: field '${name}' must be Uint8Array or ReadableStream, got ${typeof v}`);
428
+ }
429
+ return out;
430
+ }
431
+ /** Coerce a record field value into something the SQLite driver will accept. */
432
+ function adaptForWrite(col, value) {
433
+ if (value === undefined || value === null)
434
+ return null;
435
+ if (col.tag === TYPE_TAGS.JSON) {
436
+ return typeof value === "string" ? value : JSON.stringify(value);
437
+ }
438
+ if (col.tag === TYPE_TAGS.TIMESTAMP) {
439
+ if (value instanceof Date)
440
+ return value.toISOString();
441
+ if (typeof value === "number")
442
+ return new Date(value).toISOString();
443
+ return String(value);
444
+ }
445
+ if (col.tag === TYPE_TAGS.BOOLEAN) {
446
+ return value ? 1 : 0;
447
+ }
448
+ if (col.tag === TYPE_TAGS.BIGINT) {
449
+ // The injected sqlite executor may pass `bigint` through (e.g.
450
+ // `@db/sqlite`) or require `number` (e.g. the structurally-typed
451
+ // mock). Hand the value over unchanged for compatible drivers;
452
+ // numbers within safe range pass too.
453
+ return value;
454
+ }
455
+ return value;
456
+ }
457
+ /** Reconstruct an EntityRecord from a SQLite row. */
458
+ function adaptRowForRead(meta, row) {
459
+ const rec = {};
460
+ for (const col of meta.columns) {
461
+ const v = row[col.name];
462
+ if (v === null || v === undefined) {
463
+ rec[col.name] = undefined;
464
+ continue;
465
+ }
466
+ if (col.tag === TYPE_TAGS.BYTES) {
467
+ rec[col.name] = rowToBytes(v);
468
+ }
469
+ else if (col.tag === TYPE_TAGS.JSON) {
470
+ rec[col.name] = typeof v === "string" ? JSON.parse(v) : v;
471
+ }
472
+ else if (col.tag === TYPE_TAGS.TIMESTAMP) {
473
+ rec[col.name] = v instanceof Date ? v : new Date(v);
474
+ }
475
+ else if (col.tag === TYPE_TAGS.BOOLEAN) {
476
+ rec[col.name] = Boolean(v);
477
+ }
478
+ else if (col.tag === TYPE_TAGS.BIGINT) {
479
+ // SQLite returns INTEGER as either JS `number` or `bigint`
480
+ // depending on the driver. Normalise to `bigint` so callers
481
+ // get a consistent shape regardless of the underlying driver.
482
+ rec[col.name] = typeof v === "bigint" ? v : BigInt(v);
483
+ }
484
+ else
485
+ rec[col.name] = v;
486
+ }
487
+ return rec;
184
488
  }
package/esm/types.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * Cross-cutting types (`Output`, `ProtocolInterfaceNode`,
19
19
  * `DeleteResult`, `StatusResult`) still come from b3nd-core.
20
20
  */
21
- import type { B3ndError, DeleteResult, Output, StatusResult } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.22.0/src/types/types.js";
21
+ import type { B3ndError, DeleteResult, Output, StatusResult } from "./deps/jsr.io/@bandeira-tech/b3nd-core/0.25.0/src/types/types.js";
22
22
  /**
23
23
  * Payload type accepted by `write` and produced by `read`.
24
24
  *