@remnic/core 9.25.7 → 9.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +41 -41
  3. package/dist/access-audit.js +2 -2
  4. package/dist/access-authorization-probe.d.ts +1 -1
  5. package/dist/access-authorization-probe.js +42 -42
  6. package/dist/access-boundary.d.ts +1 -1
  7. package/dist/access-boundary.js +41 -41
  8. package/dist/access-cli.js +98 -97
  9. package/dist/access-cli.js.map +1 -1
  10. package/dist/access-http-lcm-compaction.d.ts +130 -0
  11. package/dist/access-http-lcm-compaction.js +10 -0
  12. package/dist/access-http.d.ts +1 -1
  13. package/dist/access-http.js +53 -52
  14. package/dist/access-identity-continuity-surface.js +41 -41
  15. package/dist/access-lcm-surface.d.ts +1 -1
  16. package/dist/access-lcm-surface.js +41 -41
  17. package/dist/access-mcp.d.ts +1 -1
  18. package/dist/access-mcp.js +47 -47
  19. package/dist/access-namespace-preflight.js +41 -41
  20. package/dist/access-observe-write-surface.d.ts +1 -1
  21. package/dist/access-observe-write-surface.js +41 -41
  22. package/dist/access-operations-batch.js +43 -43
  23. package/dist/access-operations.d.ts +4 -4
  24. package/dist/access-operations.js +45 -45
  25. package/dist/access-recall-concurrency.d.ts +1 -1
  26. package/dist/access-recall-concurrency.js +41 -41
  27. package/dist/access-recall-response.d.ts +1 -1
  28. package/dist/access-recall-response.js +41 -41
  29. package/dist/access-recall-surface.d.ts +1 -1
  30. package/dist/access-recall-surface.js +41 -41
  31. package/dist/access-schema.d.ts +92 -77
  32. package/dist/access-schema.js +8 -8
  33. package/dist/{access-service-CIdWkP6J.d.ts → access-service-DL8FJnTj.d.ts} +1 -1
  34. package/dist/access-service.d.ts +1 -1
  35. package/dist/access-service.js +41 -41
  36. package/dist/access-surface-catalog.d.ts +1 -1
  37. package/dist/adapters/index.js +7 -7
  38. package/dist/adapters/registry.js +3 -3
  39. package/dist/{auto-sync-5OE5QOZB.js → auto-sync-UPTUVE4X.js} +3 -3
  40. package/dist/briefing.js +5 -5
  41. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  42. package/dist/capsule-crypto-YO5QJ6L3.js.map +1 -0
  43. package/dist/causal-behavior.js +2 -2
  44. package/dist/causal-chain.js +2 -2
  45. package/dist/causal-consolidation.js +11 -11
  46. package/dist/causal-retrieval.js +2 -2
  47. package/dist/causal-trajectory.js +1 -1
  48. package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
  49. package/dist/{chunk-BL4JHZSO.js → chunk-2XP3PWL3.js} +40 -40
  50. package/dist/{chunk-AQ2ZQYDI.js → chunk-4KKV7W62.js} +6 -6
  51. package/dist/chunk-4Z4XNEWZ.js +86 -0
  52. package/dist/chunk-4Z4XNEWZ.js.map +1 -0
  53. package/dist/{chunk-32XTXKIA.js → chunk-67DSDRUB.js} +22 -14
  54. package/dist/chunk-67DSDRUB.js.map +1 -0
  55. package/dist/{chunk-2PHX3I5C.js → chunk-6NNNURLK.js} +8 -8
  56. package/dist/{chunk-BLAKS3CP.js → chunk-77CNV7DY.js} +4 -4
  57. package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
  58. package/dist/{chunk-227UFSEQ.js → chunk-7RZJWZ46.js} +5 -5
  59. package/dist/{chunk-WIGBTWXA.js → chunk-7XXCIO6X.js} +2 -2
  60. package/dist/{chunk-7CPOZH6Y.js → chunk-AJ7QCSIS.js} +2 -2
  61. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  62. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  63. package/dist/{chunk-UMFLAACL.js → chunk-E6EAB4J6.js} +9 -9
  64. package/dist/{chunk-WSEA52RI.js → chunk-EXM2Q555.js} +2 -2
  65. package/dist/{chunk-G6MGAE36.js → chunk-F6LSNPPB.js} +5 -5
  66. package/dist/{chunk-7KXQ44PA.js → chunk-FE5FKTCI.js} +5 -5
  67. package/dist/{chunk-5J7HJ7RT.js → chunk-H2PBM562.js} +2 -2
  68. package/dist/{chunk-OIE242WM.js → chunk-HBVOJNV4.js} +3 -3
  69. package/dist/{chunk-PS5OG75R.js → chunk-IHMXPG4P.js} +2 -2
  70. package/dist/{chunk-3UOSVTFR.js → chunk-JCVQV6QX.js} +11 -11
  71. package/dist/{chunk-P25IPRXP.js → chunk-KGTD2KZJ.js} +28 -8
  72. package/dist/chunk-KGTD2KZJ.js.map +1 -0
  73. package/dist/{chunk-NY6W6CJW.js → chunk-KPWLZBXZ.js} +2 -2
  74. package/dist/{chunk-W33DEUKR.js → chunk-LIIQPDFN.js} +2 -2
  75. package/dist/{chunk-D7MAWMMK.js → chunk-M2KGXXTL.js} +2 -2
  76. package/dist/{chunk-UOURUMBN.js → chunk-MIFF37NN.js} +2 -2
  77. package/dist/{chunk-37VPMR4E.js → chunk-NKDVXEFU.js} +6 -6
  78. package/dist/{chunk-ZPUFQGUF.js → chunk-PQPSZICJ.js} +5 -5
  79. package/dist/{chunk-ES36HTSC.js → chunk-PTN6UAUA.js} +4 -4
  80. package/dist/{chunk-KZVPTJNZ.js → chunk-PX7VDW7R.js} +75 -75
  81. package/dist/{chunk-S2BBISHU.js → chunk-RYHWZ52A.js} +4 -4
  82. package/dist/{chunk-ZN4UZWMT.js → chunk-SK3EYPKC.js} +2 -2
  83. package/dist/{chunk-TPYZ727G.js → chunk-SN3ZESWQ.js} +6 -6
  84. package/dist/{chunk-BUK2FXOL.js → chunk-TKBDJTXG.js} +4 -4
  85. package/dist/{chunk-4B77XSG5.js → chunk-UV72DEZD.js} +5 -5
  86. package/dist/{chunk-K4DP5KZA.js → chunk-UZK7VVPD.js} +14 -14
  87. package/dist/{chunk-KQAFEZQX.js → chunk-VDX2J7OX.js} +2 -2
  88. package/dist/{chunk-SSYBI43M.js → chunk-VGEH3STZ.js} +6 -6
  89. package/dist/{chunk-NRV4Y3PM.js → chunk-W4FBGYGK.js} +1 -1
  90. package/dist/{chunk-IR4LGIH6.js → chunk-WMDRLTNA.js} +2 -2
  91. package/dist/{chunk-MU7ZHSZE.js → chunk-WZFAPHIK.js} +2 -2
  92. package/dist/{chunk-JA4HEXV7.js → chunk-XAAIUXJO.js} +2 -2
  93. package/dist/{chunk-VPSAVXHM.js → chunk-XNUBF6RF.js} +7 -7
  94. package/dist/{chunk-3CHQZVAL.js → chunk-Y6E37WRG.js} +5 -5
  95. package/dist/{chunk-E5A6C4A5.js → chunk-YN4AUGNF.js} +48 -48
  96. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  97. package/dist/{chunk-7IGWPXVC.js → chunk-ZHP4SUPB.js} +2 -2
  98. package/dist/{cli-Da5kesWC.d.ts → cli-BoouX6LX.d.ts} +1 -1
  99. package/dist/cli.d.ts +2 -2
  100. package/dist/cli.js +75 -74
  101. package/dist/compounding/engine.js +5 -5
  102. package/dist/connectors/codex-materialize-runner.js +5 -5
  103. package/dist/connectors/index.js +5 -5
  104. package/dist/conversation-index/backend.js +2 -2
  105. package/dist/corpus-watermark.js +16 -16
  106. package/dist/dashboard-runtime.js +2 -2
  107. package/dist/entity-retrieval.js +5 -5
  108. package/dist/extraction.js +3 -3
  109. package/dist/{first-start-migration-BKNGOFED.js → first-start-migration-LEXL3XYL.js} +4 -4
  110. package/dist/index.d.ts +391 -391
  111. package/dist/index.js +177 -176
  112. package/dist/lcm/engine.js +3 -3
  113. package/dist/lcm/index.js +9 -9
  114. package/dist/maintenance/memory-governance.js +6 -6
  115. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +5 -5
  116. package/dist/maintenance/rebuild-memory-projection.js +7 -7
  117. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  118. package/dist/namespaces/migrate.js +15 -15
  119. package/dist/namespaces/search.js +9 -9
  120. package/dist/namespaces/storage.js +5 -5
  121. package/dist/operator-doctor-corpus.js +17 -17
  122. package/dist/operator-toolkit.js +28 -28
  123. package/dist/orchestration/maintenance.js +11 -11
  124. package/dist/orchestrator.js +98 -97
  125. package/dist/resume-bundles.js +1 -1
  126. package/dist/schemas.d.ts +76 -76
  127. package/dist/search/factory.js +8 -8
  128. package/dist/search/index.js +13 -13
  129. package/dist/search/lancedb-backend.js +2 -2
  130. package/dist/search/meilisearch-backend.js +2 -2
  131. package/dist/search/orama-backend.js +2 -2
  132. package/dist/semantic-consolidation.js +7 -7
  133. package/dist/semantic-rule-promotion.js +5 -5
  134. package/dist/semantic-rule-verifier.js +6 -6
  135. package/dist/session-namespace-bindings.d.ts +27 -0
  136. package/dist/session-namespace-bindings.js +243 -0
  137. package/dist/session-namespace-bindings.js.map +1 -0
  138. package/dist/shared-context/manager.d.ts +8 -8
  139. package/dist/storage.js +4 -4
  140. package/dist/testing/lifecycle-matrix.d.ts +100 -0
  141. package/dist/testing/lifecycle-matrix.js +149 -0
  142. package/dist/testing/lifecycle-matrix.js.map +1 -0
  143. package/dist/transfer/backup.js +4 -4
  144. package/dist/transfer/capsule-export.js +4 -4
  145. package/dist/transfer/capsule-import.js +3 -3
  146. package/dist/transfer/types.d.ts +66 -66
  147. package/dist/verified-recall.js +6 -6
  148. package/package.json +22 -2
  149. package/src/access-http-lcm-compaction.ts +116 -0
  150. package/src/access-http.test.ts +218 -0
  151. package/src/access-http.ts +8 -8
  152. package/src/access-schema-pi.test.ts +9 -0
  153. package/src/access-schema.ts +29 -4
  154. package/src/access-surface-catalog.test.ts +1 -0
  155. package/src/session-namespace-bindings.test.ts +358 -0
  156. package/src/session-namespace-bindings.ts +301 -0
  157. package/src/testing/lifecycle-matrix.ts +17 -7
  158. package/dist/chunk-32XTXKIA.js.map +0 -1
  159. package/dist/chunk-P25IPRXP.js.map +0 -1
  160. /package/dist/{capsule-crypto-7FJQINUR.js.map → access-http-lcm-compaction.js.map} +0 -0
  161. /package/dist/{auto-sync-5OE5QOZB.js.map → auto-sync-UPTUVE4X.js.map} +0 -0
  162. /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
  163. /package/dist/{chunk-BL4JHZSO.js.map → chunk-2XP3PWL3.js.map} +0 -0
  164. /package/dist/{chunk-AQ2ZQYDI.js.map → chunk-4KKV7W62.js.map} +0 -0
  165. /package/dist/{chunk-2PHX3I5C.js.map → chunk-6NNNURLK.js.map} +0 -0
  166. /package/dist/{chunk-BLAKS3CP.js.map → chunk-77CNV7DY.js.map} +0 -0
  167. /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
  168. /package/dist/{chunk-227UFSEQ.js.map → chunk-7RZJWZ46.js.map} +0 -0
  169. /package/dist/{chunk-WIGBTWXA.js.map → chunk-7XXCIO6X.js.map} +0 -0
  170. /package/dist/{chunk-7CPOZH6Y.js.map → chunk-AJ7QCSIS.js.map} +0 -0
  171. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  172. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  173. /package/dist/{chunk-UMFLAACL.js.map → chunk-E6EAB4J6.js.map} +0 -0
  174. /package/dist/{chunk-WSEA52RI.js.map → chunk-EXM2Q555.js.map} +0 -0
  175. /package/dist/{chunk-G6MGAE36.js.map → chunk-F6LSNPPB.js.map} +0 -0
  176. /package/dist/{chunk-7KXQ44PA.js.map → chunk-FE5FKTCI.js.map} +0 -0
  177. /package/dist/{chunk-5J7HJ7RT.js.map → chunk-H2PBM562.js.map} +0 -0
  178. /package/dist/{chunk-OIE242WM.js.map → chunk-HBVOJNV4.js.map} +0 -0
  179. /package/dist/{chunk-PS5OG75R.js.map → chunk-IHMXPG4P.js.map} +0 -0
  180. /package/dist/{chunk-3UOSVTFR.js.map → chunk-JCVQV6QX.js.map} +0 -0
  181. /package/dist/{chunk-NY6W6CJW.js.map → chunk-KPWLZBXZ.js.map} +0 -0
  182. /package/dist/{chunk-W33DEUKR.js.map → chunk-LIIQPDFN.js.map} +0 -0
  183. /package/dist/{chunk-D7MAWMMK.js.map → chunk-M2KGXXTL.js.map} +0 -0
  184. /package/dist/{chunk-UOURUMBN.js.map → chunk-MIFF37NN.js.map} +0 -0
  185. /package/dist/{chunk-37VPMR4E.js.map → chunk-NKDVXEFU.js.map} +0 -0
  186. /package/dist/{chunk-ZPUFQGUF.js.map → chunk-PQPSZICJ.js.map} +0 -0
  187. /package/dist/{chunk-ES36HTSC.js.map → chunk-PTN6UAUA.js.map} +0 -0
  188. /package/dist/{chunk-KZVPTJNZ.js.map → chunk-PX7VDW7R.js.map} +0 -0
  189. /package/dist/{chunk-S2BBISHU.js.map → chunk-RYHWZ52A.js.map} +0 -0
  190. /package/dist/{chunk-ZN4UZWMT.js.map → chunk-SK3EYPKC.js.map} +0 -0
  191. /package/dist/{chunk-TPYZ727G.js.map → chunk-SN3ZESWQ.js.map} +0 -0
  192. /package/dist/{chunk-BUK2FXOL.js.map → chunk-TKBDJTXG.js.map} +0 -0
  193. /package/dist/{chunk-4B77XSG5.js.map → chunk-UV72DEZD.js.map} +0 -0
  194. /package/dist/{chunk-K4DP5KZA.js.map → chunk-UZK7VVPD.js.map} +0 -0
  195. /package/dist/{chunk-KQAFEZQX.js.map → chunk-VDX2J7OX.js.map} +0 -0
  196. /package/dist/{chunk-SSYBI43M.js.map → chunk-VGEH3STZ.js.map} +0 -0
  197. /package/dist/{chunk-NRV4Y3PM.js.map → chunk-W4FBGYGK.js.map} +0 -0
  198. /package/dist/{chunk-IR4LGIH6.js.map → chunk-WMDRLTNA.js.map} +0 -0
  199. /package/dist/{chunk-MU7ZHSZE.js.map → chunk-WZFAPHIK.js.map} +0 -0
  200. /package/dist/{chunk-JA4HEXV7.js.map → chunk-XAAIUXJO.js.map} +0 -0
  201. /package/dist/{chunk-VPSAVXHM.js.map → chunk-XNUBF6RF.js.map} +0 -0
  202. /package/dist/{chunk-3CHQZVAL.js.map → chunk-Y6E37WRG.js.map} +0 -0
  203. /package/dist/{chunk-E5A6C4A5.js.map → chunk-YN4AUGNF.js.map} +0 -0
  204. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  205. /package/dist/{chunk-7IGWPXVC.js.map → chunk-ZHP4SUPB.js.map} +0 -0
  206. /package/dist/{first-start-migration-BKNGOFED.js.map → first-start-migration-LEXL3XYL.js.map} +0 -0
@@ -0,0 +1,358 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ createFileSessionNamespaceBindingStore,
9
+ createInMemorySessionNamespaceBindingStore,
10
+ SESSION_NAMESPACE_BINDING_MAX_ENTRIES,
11
+ SESSION_NAMESPACE_BINDING_MAX_NAMESPACES,
12
+ SESSION_NAMESPACE_BINDING_MAX_NAMESPACE_LENGTH,
13
+ } from "./session-namespace-bindings.js";
14
+
15
+ test("session namespace bindings persist prototype-named session keys", async () => {
16
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
17
+ const filePath = path.join(directory, "session-namespace-bindings.json");
18
+ try {
19
+ const store = createFileSessionNamespaceBindingStore(filePath);
20
+ await store.remember("__proto__", "team-prototype");
21
+
22
+ const reloaded = createFileSessionNamespaceBindingStore(filePath);
23
+ assert.deepEqual(await reloaded.namespacesFor("__proto__"), ["team-prototype"]);
24
+ } finally {
25
+ await rm(directory, { recursive: true, force: true });
26
+ }
27
+ });
28
+ test("session namespace bindings propagate malformed-file reads without overwriting them", async () => {
29
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
30
+ const filePath = path.join(directory, "session-namespace-bindings.json");
31
+ try {
32
+ await writeFile(filePath, "{", "utf8");
33
+ const store = createFileSessionNamespaceBindingStore(filePath);
34
+
35
+ await assert.rejects(() => store.namespacesFor("malformed-session"), SyntaxError);
36
+ await assert.rejects(() => store.remember("malformed-session", "team-known"), SyntaxError);
37
+ assert.equal(await readFile(filePath, "utf8"), "{");
38
+ } finally {
39
+ await rm(directory, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("session namespace bindings reject structurally invalid files without overwriting them", async () => {
44
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
45
+ const filePath = path.join(directory, "session-namespace-bindings.json");
46
+ try {
47
+ await writeFile(filePath, JSON.stringify({ entries: null }), "utf8");
48
+ const store = createFileSessionNamespaceBindingStore(filePath);
49
+
50
+ await assert.rejects(() => store.namespacesFor("invalid-structure-session"), /invalid structure/);
51
+ await assert.rejects(() => store.remember("invalid-structure-session", "team-known"), /invalid structure/);
52
+ assert.equal(await readFile(filePath, "utf8"), JSON.stringify({ entries: null }));
53
+ } finally {
54
+ await rm(directory, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test("session namespace bindings reject unsupported file versions without overwriting them", async () => {
59
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
60
+ const filePath = path.join(directory, "session-namespace-bindings.json");
61
+ const updatedAt = new Date().toISOString();
62
+ const validEntry = { namespaces: ["team-known"], updatedAt };
63
+ const invalidFiles = [
64
+ { entries: { "versionless-session": validEntry } },
65
+ { version: 2, entries: { "newer-version-session": validEntry } },
66
+ ];
67
+ try {
68
+ for (const file of invalidFiles) {
69
+ const raw = JSON.stringify(file);
70
+ await writeFile(filePath, raw, "utf8");
71
+ const store = createFileSessionNamespaceBindingStore(filePath);
72
+
73
+ await assert.rejects(
74
+ () => store.namespacesFor("unsupported-version-session"),
75
+ /invalid structure/,
76
+ );
77
+ await assert.rejects(
78
+ () => store.remember("unsupported-version-session", "team-known"),
79
+ /invalid structure/,
80
+ );
81
+ assert.equal(await readFile(filePath, "utf8"), raw);
82
+ }
83
+ } finally {
84
+ await rm(directory, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("session namespace bindings reject malformed persisted entries", async () => {
89
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
90
+ const filePath = path.join(directory, "session-namespace-bindings.json");
91
+ const updatedAt = new Date().toISOString();
92
+ const invalidEntries = [
93
+ { "null-entry": null },
94
+ { "missing-namespaces": { updatedAt } },
95
+ { "missing-updated-at": { namespaces: ["team-known"] } },
96
+ { "invalid-namespaces": { namespaces: ["team-known", null], updatedAt } },
97
+ {
98
+ "oversized-namespace": {
99
+ namespaces: ["x".repeat(SESSION_NAMESPACE_BINDING_MAX_NAMESPACE_LENGTH + 1)],
100
+ updatedAt,
101
+ },
102
+ },
103
+ ];
104
+ try {
105
+ for (const entries of invalidEntries) {
106
+ const raw = JSON.stringify({ version: 1, entries });
107
+ await writeFile(filePath, raw, "utf8");
108
+ const store = createFileSessionNamespaceBindingStore(filePath);
109
+
110
+ await assert.rejects(
111
+ () => store.namespacesFor("invalid-entry-session"),
112
+ /entry has invalid structure/,
113
+ );
114
+ await assert.rejects(
115
+ () => store.remember("invalid-entry-session", "team-known"),
116
+ /entry has invalid structure/,
117
+ );
118
+ assert.equal(await readFile(filePath, "utf8"), raw);
119
+ }
120
+ } finally {
121
+ await rm(directory, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("session namespace bindings retain concurrent scope observations", async () => {
126
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
127
+ const filePath = path.join(directory, "session-namespace-bindings.json");
128
+ try {
129
+ await Promise.all([
130
+ createFileSessionNamespaceBindingStore(filePath).remember("session-1", "team-first"),
131
+ createFileSessionNamespaceBindingStore(filePath).remember("session-1", "team-second"),
132
+ ]);
133
+
134
+ const reloaded = createFileSessionNamespaceBindingStore(filePath);
135
+ assert.deepEqual((await reloaded.namespacesFor("session-1")).sort(), ["team-first", "team-second"]);
136
+ } finally {
137
+ await rm(directory, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("session namespace bindings persist an explicit default scope", async () => {
142
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
143
+ const filePath = path.join(directory, "session-namespace-bindings.json");
144
+ try {
145
+ const store = createFileSessionNamespaceBindingStore(filePath);
146
+ await store.remember("session-1", "team-named");
147
+ await store.remember("session-1", "");
148
+
149
+ const reloaded = createFileSessionNamespaceBindingStore(filePath);
150
+ assert.deepEqual(await reloaded.namespacesFor("session-1"), ["team-named", ""]);
151
+ } finally {
152
+ await rm(directory, { recursive: true, force: true });
153
+ }
154
+ });
155
+
156
+ test("session namespace bindings prune expired and overflowed persisted history", async () => {
157
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
158
+ const filePath = path.join(directory, "session-namespace-bindings.json");
159
+ const now = Date.now();
160
+ const entries = Object.fromEntries(
161
+ Array.from({ length: 1_000 }, (_unused, index) => [
162
+ `session-${index}`,
163
+ {
164
+ namespaces: [`team-${index}`],
165
+ updatedAt: new Date(now - index).toISOString(),
166
+ },
167
+ ])
168
+ ) as Record<string, { namespaces: string[]; updatedAt: string }>;
169
+ entries["stale-session"] = {
170
+ namespaces: ["team-stale"],
171
+ updatedAt: new Date(now - 8 * 24 * 60 * 60 * 1_000).toISOString(),
172
+ };
173
+ try {
174
+ await writeFile(filePath, JSON.stringify({ version: 1, entries }), "utf8");
175
+ const store = createFileSessionNamespaceBindingStore(filePath);
176
+
177
+ assert.deepEqual(await store.namespacesFor("stale-session"), []);
178
+ await store.remember("current-session", "team-current");
179
+
180
+ const persisted = JSON.parse(await readFile(filePath, "utf8")) as {
181
+ entries: Record<string, { namespaces: string[] }>;
182
+ };
183
+ assert.equal(Object.keys(persisted.entries).length, 1_000);
184
+ assert.equal("stale-session" in persisted.entries, false);
185
+ assert.deepEqual(await store.namespacesFor("current-session"), ["team-current"]);
186
+ } finally {
187
+ await rm(directory, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test("in-memory session namespace bindings cap inactive routing history", async () => {
192
+ const store = createInMemorySessionNamespaceBindingStore();
193
+ for (let index = 0; index <= 1_000; index += 1) {
194
+ await store.remember(`session-${index}`, `team-${index}`);
195
+ }
196
+
197
+ assert.deepEqual(await store.namespacesFor("session-0"), []);
198
+ assert.deepEqual(await store.namespacesFor("session-1000"), ["team-1000"]);
199
+ });
200
+
201
+ test("in-memory session namespace bindings retain sparse active sessions", async () => {
202
+ const store = createInMemorySessionNamespaceBindingStore();
203
+ for (let index = 0; index < SESSION_NAMESPACE_BINDING_MAX_ENTRIES; index += 1) {
204
+ await store.remember(`session-${index}`, `team-${index}`);
205
+ }
206
+
207
+ assert.deepEqual(await store.namespacesFor("session-0"), ["team-0"]);
208
+ await store.remember("overflow-session", "team-overflow");
209
+
210
+ assert.deepEqual(await store.namespacesFor("session-0"), ["team-0"]);
211
+ assert.deepEqual(await store.namespacesFor("session-1"), []);
212
+ });
213
+
214
+ test("session namespace bindings refresh a sparse active binding", async () => {
215
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
216
+ const filePath = path.join(directory, "session-namespace-bindings.json");
217
+ const updatedAt = new Date(Date.now() - 60 * 60 * 1_000).toISOString();
218
+ try {
219
+ await writeFile(
220
+ filePath,
221
+ JSON.stringify({
222
+ version: 1,
223
+ entries: {
224
+ "active-session": { namespaces: ["team-active"], updatedAt },
225
+ },
226
+ }),
227
+ "utf8",
228
+ );
229
+ const store = createFileSessionNamespaceBindingStore(filePath);
230
+
231
+ assert.deepEqual(await store.namespacesFor("active-session"), ["team-active"]);
232
+
233
+ const persisted = JSON.parse(await readFile(filePath, "utf8")) as {
234
+ entries: Record<string, { updatedAt: string }>;
235
+ };
236
+ assert.ok(Date.parse(persisted.entries["active-session"].updatedAt) > Date.parse(updatedAt));
237
+ } finally {
238
+ await rm(directory, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ test("session namespace bindings return known scope when timestamp refresh fails", async () => {
243
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
244
+ const filePath = path.join(directory, "session-namespace-bindings.json");
245
+ const updatedAt = new Date(Date.now() - 60 * 60 * 1_000).toISOString();
246
+ try {
247
+ await writeFile(
248
+ filePath,
249
+ JSON.stringify({
250
+ version: 1,
251
+ entries: {
252
+ "refresh-failure-session": { namespaces: ["team-known"], updatedAt },
253
+ },
254
+ }),
255
+ "utf8",
256
+ );
257
+ let writeAttempts = 0;
258
+ const store = createFileSessionNamespaceBindingStore(filePath, {
259
+ writeBindingFile: async () => {
260
+ writeAttempts += 1;
261
+ throw new Error("forced refresh write failure");
262
+ },
263
+ });
264
+
265
+ assert.deepEqual(await store.namespacesFor("refresh-failure-session"), ["team-known"]);
266
+ assert.equal(writeAttempts, 1);
267
+
268
+ const persisted = JSON.parse(await readFile(filePath, "utf8")) as {
269
+ entries: Record<string, { updatedAt: string }>;
270
+ };
271
+ assert.equal(persisted.entries["refresh-failure-session"].updatedAt, updatedAt);
272
+ } finally {
273
+ await rm(directory, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("session namespace bindings retain failed refreshes in volatile state", async () => {
278
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
279
+ const filePath = path.join(directory, "session-namespace-bindings.json");
280
+ const updatedAt = new Date(Date.now() - 60 * 60 * 1_000).toISOString();
281
+ try {
282
+ await writeFile(
283
+ filePath,
284
+ JSON.stringify({
285
+ version: 1,
286
+ entries: {
287
+ "volatile-refresh-session": { namespaces: ["team-known"], updatedAt },
288
+ },
289
+ }),
290
+ "utf8",
291
+ );
292
+ const store = createFileSessionNamespaceBindingStore(filePath, {
293
+ writeBindingFile: async () => {
294
+ throw new Error("forced refresh write failure");
295
+ },
296
+ });
297
+
298
+ assert.deepEqual(await store.namespacesFor("volatile-refresh-session"), ["team-known"]);
299
+ await writeFile(
300
+ filePath,
301
+ JSON.stringify({
302
+ version: 1,
303
+ entries: {
304
+ "volatile-refresh-session": {
305
+ namespaces: ["team-known"],
306
+ updatedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000).toISOString(),
307
+ },
308
+ },
309
+ }),
310
+ "utf8",
311
+ );
312
+ assert.deepEqual(await store.namespacesFor("volatile-refresh-session"), ["team-known"]);
313
+ } finally {
314
+ await rm(directory, { recursive: true, force: true });
315
+ }
316
+ });
317
+
318
+ test("session namespace bindings cap each session's namespace history", async () => {
319
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
320
+ const filePath = path.join(directory, "session-namespace-bindings.json");
321
+ try {
322
+ const fileStore = createFileSessionNamespaceBindingStore(filePath);
323
+ const memoryStore = createInMemorySessionNamespaceBindingStore();
324
+ for (let index = 0; index <= SESSION_NAMESPACE_BINDING_MAX_NAMESPACES; index += 1) {
325
+ const namespace = `team-${index}`;
326
+ await fileStore.remember("rebound-session", namespace);
327
+ await memoryStore.remember("rebound-session", namespace);
328
+ }
329
+
330
+ const expected = Array.from(
331
+ { length: SESSION_NAMESPACE_BINDING_MAX_NAMESPACES },
332
+ (_unused, index) => `team-${index + 1}`,
333
+ );
334
+ assert.deepEqual(await fileStore.namespacesFor("rebound-session"), expected);
335
+ assert.deepEqual(await memoryStore.namespacesFor("rebound-session"), expected);
336
+ } finally {
337
+ await rm(directory, { recursive: true, force: true });
338
+ }
339
+ });
340
+
341
+ test("session namespace bindings clean temporary files after atomic write failure", async () => {
342
+ const directory = await mkdtemp(path.join(os.tmpdir(), "remnic-namespace-bindings-"));
343
+ const filePath = path.join(directory, "session-namespace-bindings.json");
344
+ const store = createFileSessionNamespaceBindingStore(filePath, {
345
+ renameBindingFile: async () => {
346
+ throw new Error("forced atomic rename failure");
347
+ },
348
+ });
349
+ try {
350
+ await assert.rejects(
351
+ () => store.remember("atomic-failure-session", "team-known"),
352
+ /forced atomic rename failure/,
353
+ );
354
+ assert.deepEqual(await readdir(directory), []);
355
+ } finally {
356
+ await rm(directory, { recursive: true, force: true });
357
+ }
358
+ });
@@ -0,0 +1,301 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export interface SessionNamespaceBindingStore {
6
+ namespacesFor(sessionKey: string): Promise<string[]>;
7
+ remember(sessionKey: string, namespace: string): Promise<void>;
8
+ replace?(sessionKey: string, namespaces: string[]): Promise<void>;
9
+ }
10
+
11
+ export const SESSION_NAMESPACE_BINDING_MAX_ENTRIES = 1_000;
12
+ export const SESSION_NAMESPACE_BINDING_MAX_NAMESPACES = 64;
13
+ export const SESSION_NAMESPACE_BINDING_MAX_NAMESPACE_LENGTH = 256;
14
+ export const SESSION_NAMESPACE_BINDING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
15
+
16
+ interface NamespaceBindingEntry {
17
+ namespaces: string[];
18
+ updatedAt: string;
19
+ }
20
+
21
+ interface NamespaceBindingFile {
22
+ version: 1;
23
+ entries: Record<string, NamespaceBindingEntry>;
24
+ }
25
+ type BindingFileWriter = (filePath: string, bindings: NamespaceBindingFile) => Promise<void>;
26
+
27
+ const fileWriteChains = new Map<string, Promise<void>>();
28
+ const volatileRefreshes = new Map<string, Map<string, string>>();
29
+
30
+ function retainVolatileRefresh(filePath: string, sessionKey: string, updatedAt: string): void {
31
+ const refreshes = volatileRefreshes.get(filePath) ?? new Map<string, string>();
32
+ refreshes.set(sessionKey, updatedAt);
33
+ volatileRefreshes.set(filePath, refreshes);
34
+ }
35
+
36
+ function clearVolatileRefresh(filePath: string, sessionKey: string): void {
37
+ const refreshes = volatileRefreshes.get(filePath);
38
+ if (refreshes === undefined) return;
39
+ refreshes.delete(sessionKey);
40
+ if (refreshes.size === 0) volatileRefreshes.delete(filePath);
41
+ }
42
+
43
+ function applyVolatileRefreshes(filePath: string, entries: Record<string, NamespaceBindingEntry>): void {
44
+ const refreshes = volatileRefreshes.get(filePath);
45
+ if (refreshes === undefined) return;
46
+ for (const [key, updatedAt] of refreshes) {
47
+ const entry = entries[key];
48
+ if (entry === undefined) {
49
+ refreshes.delete(key);
50
+ continue;
51
+ }
52
+ const persistedAt = Date.parse(entry.updatedAt);
53
+ const refreshedAt = Date.parse(updatedAt);
54
+ if (Number.isFinite(refreshedAt) && (!Number.isFinite(persistedAt) || refreshedAt > persistedAt)) {
55
+ entry.updatedAt = updatedAt;
56
+ } else {
57
+ refreshes.delete(key);
58
+ }
59
+ }
60
+ if (refreshes.size === 0) volatileRefreshes.delete(filePath);
61
+ }
62
+
63
+ function emptyBindingFile(): NamespaceBindingFile {
64
+ return { version: 1, entries: Object.create(null) as Record<string, NamespaceBindingEntry> };
65
+ }
66
+
67
+ function capNamespaceHistory(namespaces: string[]): string[] {
68
+ return namespaces.length > SESSION_NAMESPACE_BINDING_MAX_NAMESPACES
69
+ ? namespaces.slice(-SESSION_NAMESPACE_BINDING_MAX_NAMESPACES)
70
+ : namespaces;
71
+ }
72
+
73
+ function normalizeNamespaces(value: unknown): string[] {
74
+ if (!Array.isArray(value)) return [];
75
+ const namespaces: string[] = [];
76
+ for (const entry of value) {
77
+ if (typeof entry !== "string") continue;
78
+ const namespace = entry.trim();
79
+ const existing = namespaces.indexOf(namespace);
80
+ if (existing >= 0) namespaces.splice(existing, 1);
81
+ namespaces.push(namespace);
82
+ }
83
+ return capNamespaceHistory(namespaces);
84
+ }
85
+
86
+ function addNamespace(namespaces: string[], namespace: string): string[] {
87
+ const normalized = namespace.trim();
88
+ return capNamespaceHistory([
89
+ ...namespaces.filter((candidate) => candidate !== normalized),
90
+ normalized,
91
+ ]);
92
+ }
93
+
94
+ function pruneBindingEntries(
95
+ entries: Record<string, NamespaceBindingEntry>,
96
+ protectedKey?: string
97
+ ): Record<string, NamespaceBindingEntry> {
98
+ const cutoff = Date.now() - SESSION_NAMESPACE_BINDING_MAX_AGE_MS;
99
+ const retained = Object.entries(entries)
100
+ .filter(([_key, entry]) => {
101
+ const updatedAt = Date.parse(entry.updatedAt);
102
+ return Number.isFinite(updatedAt) && updatedAt >= cutoff;
103
+ })
104
+ .sort(([leftKey, left], [rightKey, right]) => {
105
+ if (leftKey === rightKey) return 0;
106
+ if (leftKey === protectedKey) return -1;
107
+ if (rightKey === protectedKey) return 1;
108
+ const leftAt = Date.parse(left.updatedAt);
109
+ const rightAt = Date.parse(right.updatedAt);
110
+ if (leftAt !== rightAt) return rightAt > leftAt ? 1 : -1;
111
+ return leftKey < rightKey ? -1 : 1;
112
+ })
113
+ .slice(0, SESSION_NAMESPACE_BINDING_MAX_ENTRIES);
114
+ const pruned = Object.create(null) as Record<string, NamespaceBindingEntry>;
115
+ for (const [key, entry] of retained) pruned[key] = entry;
116
+ return pruned;
117
+ }
118
+
119
+ function encodeSessionKey(sessionKey: string): string {
120
+ return encodeURIComponent(sessionKey);
121
+ }
122
+
123
+ async function readBindingFile(filePath: string): Promise<NamespaceBindingFile> {
124
+ let raw: string;
125
+ try {
126
+ raw = await readFile(filePath, "utf8");
127
+ } catch (err) {
128
+ if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
129
+ volatileRefreshes.delete(filePath);
130
+ return emptyBindingFile();
131
+ }
132
+ throw err;
133
+ }
134
+ const parsed = JSON.parse(raw) as Partial<NamespaceBindingFile>;
135
+ if (
136
+ !parsed ||
137
+ typeof parsed !== "object" ||
138
+ Array.isArray(parsed) ||
139
+ parsed.version !== 1 ||
140
+ !parsed.entries ||
141
+ typeof parsed.entries !== "object" ||
142
+ Array.isArray(parsed.entries)
143
+ ) {
144
+ throw new Error("session namespace binding file has invalid structure");
145
+ }
146
+ const entries = Object.create(null) as Record<string, NamespaceBindingEntry>;
147
+ for (const [key, value] of Object.entries(parsed.entries)) {
148
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
149
+ throw new Error("session namespace binding entry has invalid structure");
150
+ }
151
+ const entry = value as Partial<NamespaceBindingEntry>;
152
+ if (
153
+ typeof entry.updatedAt !== "string" ||
154
+ !Number.isFinite(Date.parse(entry.updatedAt)) ||
155
+ !Array.isArray(entry.namespaces) ||
156
+ entry.namespaces.length === 0 ||
157
+ entry.namespaces.some(
158
+ (namespace) =>
159
+ typeof namespace !== "string" ||
160
+ namespace.trim().length > SESSION_NAMESPACE_BINDING_MAX_NAMESPACE_LENGTH,
161
+ )
162
+ ) {
163
+ throw new Error("session namespace binding entry has invalid structure");
164
+ }
165
+ const namespaces = normalizeNamespaces(entry.namespaces);
166
+ if (namespaces.length === 0) {
167
+ throw new Error("session namespace binding entry has invalid structure");
168
+ }
169
+ entries[key] = { namespaces, updatedAt: entry.updatedAt };
170
+ }
171
+ applyVolatileRefreshes(filePath, entries);
172
+ return { version: 1, entries: pruneBindingEntries(entries) };
173
+ }
174
+
175
+ async function writeBindingFile(
176
+ filePath: string,
177
+ bindings: NamespaceBindingFile,
178
+ renameFile: typeof rename = rename,
179
+ ): Promise<void> {
180
+ await mkdir(path.dirname(filePath), { recursive: true });
181
+ const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
182
+ try {
183
+ await writeFile(temporaryPath, JSON.stringify(bindings, null, 2), "utf8");
184
+ await renameFile(temporaryPath, filePath);
185
+ } catch (err) {
186
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
187
+ throw err;
188
+ }
189
+ }
190
+
191
+ async function queueFileWrite<TResult>(
192
+ filePath: string,
193
+ operation: () => Promise<TResult>,
194
+ ): Promise<TResult> {
195
+ const prior = fileWriteChains.get(filePath) ?? Promise.resolve();
196
+ const run = prior.catch(() => undefined).then(operation);
197
+ fileWriteChains.set(
198
+ filePath,
199
+ run.then(
200
+ () => undefined,
201
+ () => undefined,
202
+ ),
203
+ );
204
+ return run;
205
+ }
206
+
207
+ export function createInMemorySessionNamespaceBindingStore(): SessionNamespaceBindingStore {
208
+ const entries = new Map<string, string[]>();
209
+ return {
210
+ async namespacesFor(sessionKey: string): Promise<string[]> {
211
+ const namespaces = entries.get(sessionKey);
212
+ if (namespaces === undefined) return [];
213
+ entries.delete(sessionKey);
214
+ entries.set(sessionKey, namespaces);
215
+ return [...namespaces];
216
+ },
217
+ async remember(sessionKey: string, namespace: string): Promise<void> {
218
+ const existing = entries.get(sessionKey) ?? [];
219
+ entries.delete(sessionKey);
220
+ entries.set(sessionKey, addNamespace(existing, namespace));
221
+ while (entries.size > SESSION_NAMESPACE_BINDING_MAX_ENTRIES) {
222
+ const oldest = entries.keys().next().value;
223
+ if (typeof oldest !== "string") break;
224
+ entries.delete(oldest);
225
+ }
226
+ },
227
+ async replace(sessionKey: string, namespaces: string[]): Promise<void> {
228
+ entries.delete(sessionKey);
229
+ if (namespaces.length > 0) {
230
+ entries.set(sessionKey, capNamespaceHistory([...namespaces]));
231
+ }
232
+ while (entries.size > SESSION_NAMESPACE_BINDING_MAX_ENTRIES) {
233
+ const oldest = entries.keys().next().value;
234
+ if (typeof oldest !== "string") break;
235
+ entries.delete(oldest);
236
+ }
237
+ },
238
+ };
239
+ }
240
+
241
+ export function createFileSessionNamespaceBindingStore(
242
+ filePath: string,
243
+ options: { writeBindingFile?: BindingFileWriter; renameBindingFile?: typeof rename } = {},
244
+ ): SessionNamespaceBindingStore {
245
+ const write =
246
+ options.writeBindingFile ??
247
+ ((targetPath: string, bindings: NamespaceBindingFile) =>
248
+ writeBindingFile(targetPath, bindings, options.renameBindingFile));
249
+ return {
250
+ async namespacesFor(sessionKey: string): Promise<string[]> {
251
+ const key = encodeSessionKey(sessionKey);
252
+ return queueFileWrite(filePath, async () => {
253
+ const bindings = await readBindingFile(filePath);
254
+ const entry = bindings.entries[key];
255
+ if (entry === undefined) return [];
256
+ const namespaces = [...entry.namespaces];
257
+ const updatedAt = new Date().toISOString();
258
+ entry.updatedAt = updatedAt;
259
+ bindings.entries = pruneBindingEntries(bindings.entries, key);
260
+ try {
261
+ await write(filePath, bindings);
262
+ clearVolatileRefresh(filePath, key);
263
+ } catch {
264
+ retainVolatileRefresh(filePath, key, updatedAt);
265
+ return namespaces;
266
+ }
267
+ return namespaces;
268
+ });
269
+ },
270
+ async remember(sessionKey: string, namespace: string): Promise<void> {
271
+ const key = encodeSessionKey(sessionKey);
272
+ await queueFileWrite(filePath, async () => {
273
+ const bindings = await readBindingFile(filePath);
274
+ const existing = bindings.entries[key]?.namespaces ?? [];
275
+ bindings.entries[key] = {
276
+ namespaces: addNamespace(existing, namespace),
277
+ updatedAt: new Date().toISOString(),
278
+ };
279
+ bindings.entries = pruneBindingEntries(bindings.entries, key);
280
+ await write(filePath, bindings);
281
+ });
282
+ },
283
+ async replace(sessionKey: string, namespaces: string[]): Promise<void> {
284
+ const key = encodeSessionKey(sessionKey);
285
+ await queueFileWrite(filePath, async () => {
286
+ const bindings = await readBindingFile(filePath);
287
+ if (namespaces.length === 0) {
288
+ delete bindings.entries[key];
289
+ } else {
290
+ bindings.entries[key] = {
291
+ namespaces: capNamespaceHistory([...namespaces]),
292
+ updatedAt: new Date().toISOString(),
293
+ };
294
+ }
295
+ bindings.entries = pruneBindingEntries(bindings.entries, key);
296
+ await write(filePath, bindings);
297
+ clearVolatileRefresh(filePath, key);
298
+ });
299
+ },
300
+ };
301
+ }