fuli-context 0.2.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 (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,437 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { CandidateStatus, FactStatus, isCurrentFact, nowIso } from '../models.js';
4
+ import { FileEpisodeIndex } from './file/episode-index.js';
5
+ import {
6
+ fileCorrectionEpisodeEvidencePreviews,
7
+ fileEpisodeEvidencePreview
8
+ } from './file/episode-evidence.js';
9
+ import { searchFileFacts, searchFileFactsPage } from './file/fact-search.js';
10
+ import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
11
+ import {
12
+ validateCandidateRecord,
13
+ validateFactRecord,
14
+ validateOutboxRecord
15
+ } from './record-validation.js';
16
+ import { normalizeSnapshot } from './snapshot-defaults.js';
17
+
18
+ export class FileStore {
19
+ #committedData;
20
+ #episodeIndex;
21
+
22
+ constructor(filePath, { jsonFileIo } = {}) {
23
+ this.filePath = filePath;
24
+ this.memoryOnly = filePath === ':memory:';
25
+ this.jsonFileIo = jsonFileIo;
26
+ this.transactionActive = false;
27
+ this.transactionDirty = false;
28
+ this.data = this.memoryOnly ? normalizeSnapshot() : this.#load();
29
+ this.#committedData = cloneData(this.data);
30
+ this.#episodeIndex = new FileEpisodeIndex(this.data.episodes);
31
+ }
32
+
33
+ transaction(fn, _options = {}) {
34
+ if (this.transactionActive) {
35
+ throw new TypeError('Nested FileStore transactions are not supported');
36
+ }
37
+
38
+ if (fn.constructor?.name === 'AsyncFunction') {
39
+ throw new TypeError('Transaction callback must be synchronous');
40
+ }
41
+
42
+ const snapshot = cloneData(this.data);
43
+ this.transactionActive = true;
44
+ this.transactionDirty = false;
45
+ try {
46
+ const result = fn(this);
47
+ if (isThenable(result)) {
48
+ if (typeof result.catch === 'function') result.catch(() => {});
49
+ throw new TypeError('Transaction callback must be synchronous');
50
+ }
51
+
52
+ const dirty = this.transactionDirty;
53
+ this.transactionActive = false;
54
+ this.transactionDirty = false;
55
+ if (dirty) this.#commit();
56
+ return result;
57
+ } catch (error) {
58
+ this.data = snapshot;
59
+ this.#episodeIndex.rebuild(this.data.episodes);
60
+ this.transactionActive = false;
61
+ this.transactionDirty = false;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ createSpace(name, kind, description = null) {
67
+ const existing = this.findSpaceByName(name);
68
+ if (existing) return existing;
69
+
70
+ const space = {
71
+ id: randomUUID(),
72
+ name,
73
+ kind,
74
+ description,
75
+ createdAt: nowIso()
76
+ };
77
+ this.data.spaces.push(space);
78
+ this.#save();
79
+ return cloneData(space);
80
+ }
81
+
82
+ listSpaces() {
83
+ return cloneData(this.data.spaces);
84
+ }
85
+
86
+ findSpaceByName(name) {
87
+ return cloneData(this.data.spaces.find((space) => space.name === name) ?? null);
88
+ }
89
+
90
+ getSpace(id) {
91
+ return cloneData(this.data.spaces.find((space) => space.id === id) ?? null);
92
+ }
93
+
94
+ subscribe(personalSpaceId, spaceId, mode = 'latest') {
95
+ const existing = this.data.subscriptions.find(
96
+ (subscription) =>
97
+ subscription.personalSpaceId === personalSpaceId && subscription.spaceId === spaceId
98
+ );
99
+ if (existing) return cloneData(existing);
100
+
101
+ const subscription = { personalSpaceId, spaceId, mode, createdAt: nowIso() };
102
+ this.data.subscriptions.push(subscription);
103
+ this.#save();
104
+ return cloneData(subscription);
105
+ }
106
+
107
+ listSubscriptions() {
108
+ return cloneData(this.data.subscriptions);
109
+ }
110
+
111
+ subscriptionsFor(personalSpaceId) {
112
+ return cloneData(this.data.subscriptions.filter(
113
+ (subscription) => subscription.personalSpaceId === personalSpaceId
114
+ ));
115
+ }
116
+
117
+ addEpisode(spaceId, sourceKind, body, sourceUri = null, metadata = {}) {
118
+ const episode = {
119
+ id: randomUUID(),
120
+ spaceId,
121
+ sourceKind,
122
+ body,
123
+ sourceUri,
124
+ metadata: cloneData(metadata),
125
+ createdAt: nowIso()
126
+ };
127
+ this.data.episodes.push(episode);
128
+ this.#episodeIndex.add(episode);
129
+ this.#save();
130
+ return cloneData(episode);
131
+ }
132
+
133
+ getEpisode(id) {
134
+ return cloneData(this.#episodeIndex.get(id));
135
+ }
136
+
137
+ listEpisodes() {
138
+ return cloneData(this.data.episodes);
139
+ }
140
+
141
+ episodeEvidencePreview(spaceId, episodeId, options = {}) {
142
+ return cloneData(fileEpisodeEvidencePreview(
143
+ this.#episodeIndex,
144
+ spaceId,
145
+ episodeId,
146
+ options
147
+ ));
148
+ }
149
+
150
+ correctionEpisodeEvidencePreviews(spaceId, factIds, options = {}) {
151
+ return cloneData(fileCorrectionEpisodeEvidencePreviews(
152
+ this.#episodeIndex,
153
+ spaceId,
154
+ factIds,
155
+ options
156
+ ));
157
+ }
158
+
159
+ addFact(fact) {
160
+ if (fact.id && this.data.facts.some((item) => item.id === fact.id)) {
161
+ throw new Error(`Duplicate fact id: ${fact.id}`);
162
+ }
163
+
164
+ const stored = {
165
+ id: fact.id || randomUUID(),
166
+ spaceId: fact.spaceId,
167
+ subject: fact.subject,
168
+ predicate: fact.predicate,
169
+ object: fact.object,
170
+ sourceEpisodeId: fact.sourceEpisodeId,
171
+ status: fact.status ?? FactStatus.CONFIRMED,
172
+ confidence: fact.confidence ?? 1,
173
+ sensitivity: fact.sensitivity ?? 'normal',
174
+ scope: fact.scope ?? 'personal',
175
+ validAt: fact.validAt ?? nowIso(),
176
+ invalidAt: fact.invalidAt ?? null,
177
+ replacedByFactId: fact.replacedByFactId ?? null
178
+ };
179
+ validateFactRecord(stored);
180
+ this.data.facts.push(stored);
181
+ this.#save();
182
+ return cloneData(stored);
183
+ }
184
+
185
+ getFact(id) {
186
+ return cloneData(this.data.facts.find((fact) => fact.id === id) ?? null);
187
+ }
188
+
189
+ updateFact(id, updates) {
190
+ const fact = this.data.facts.find((item) => item.id === id);
191
+ if (!fact) {
192
+ throw new Error(`Fact not found: ${id}`);
193
+ }
194
+
195
+ const updated = { ...fact, ...updates, id };
196
+ validateFactRecord(updated);
197
+ Object.assign(fact, updated);
198
+ this.#save();
199
+ return cloneData(fact);
200
+ }
201
+
202
+ invalidateFact(factId, replacedByFactId = null) {
203
+ const fact = this.data.facts.find((item) => item.id === factId);
204
+ if (!fact || fact.invalidAt) return;
205
+
206
+ fact.invalidAt = nowIso();
207
+ fact.replacedByFactId = replacedByFactId;
208
+ this.#save();
209
+ }
210
+
211
+ currentFacts(spaceId) {
212
+ return cloneData(
213
+ this.data.facts.filter((fact) => fact.spaceId === spaceId && isCurrentFact(fact))
214
+ );
215
+ }
216
+
217
+ listFacts(options = {}) {
218
+ const includeHistorical = options.includeHistorical ?? false;
219
+ return cloneData(
220
+ this.data.facts.filter((fact) => includeHistorical || isCurrentFact(fact))
221
+ );
222
+ }
223
+
224
+ timeline(spaceId, subject) {
225
+ return cloneData(this.data.facts
226
+ .filter((fact) => fact.spaceId === spaceId && fact.subject === subject)
227
+ .sort((left, right) => left.validAt.localeCompare(right.validAt)));
228
+ }
229
+
230
+ searchFacts(spaceIds, query, options = {}) {
231
+ return cloneData(searchFileFacts(
232
+ this.data.facts,
233
+ this.#episodeIndex,
234
+ spaceIds,
235
+ query,
236
+ options
237
+ ));
238
+ }
239
+
240
+ searchFactsPage(spaceIds, query, options = {}) {
241
+ return cloneData(searchFileFactsPage(
242
+ this.data.facts,
243
+ this.#episodeIndex,
244
+ spaceIds,
245
+ query,
246
+ options
247
+ ));
248
+ }
249
+
250
+ addCandidate(candidate) {
251
+ if (candidate.id && this.data.candidates.some((item) => item.id === candidate.id)) {
252
+ throw new Error(`Duplicate candidate id: ${candidate.id}`);
253
+ }
254
+
255
+ const status = candidate.status ?? CandidateStatus.PENDING;
256
+ const createdAt = candidate.createdAt ?? nowIso();
257
+ const stored = {
258
+ id: candidate.id || randomUUID(),
259
+ personalSpaceId: candidate.personalSpaceId,
260
+ targetSpaceId: candidate.targetSpaceId ?? null,
261
+ episodeId: candidate.episodeId,
262
+ reason: candidate.reason,
263
+ status,
264
+ createdAt,
265
+ decidedAt: candidate.decidedAt ?? (
266
+ status === CandidateStatus.PENDING ? null : createdAt
267
+ )
268
+ };
269
+ validateCandidateRecord(stored);
270
+ this.data.candidates.push(stored);
271
+ this.#save();
272
+ return cloneData(stored);
273
+ }
274
+
275
+ pendingCandidates(personalSpaceId) {
276
+ return cloneData(this.data.candidates.filter(
277
+ (candidate) =>
278
+ candidate.personalSpaceId === personalSpaceId &&
279
+ candidate.status === CandidateStatus.PENDING
280
+ ));
281
+ }
282
+
283
+ getCandidate(id) {
284
+ return cloneData(this.data.candidates.find((candidate) => candidate.id === id) ?? null);
285
+ }
286
+
287
+ listCandidates() {
288
+ return cloneData(this.data.candidates);
289
+ }
290
+
291
+ updateCandidateStatus(id, status) {
292
+ const candidate = this.data.candidates.find((item) => item.id === id);
293
+ if (!candidate) {
294
+ throw new Error(`Candidate not found: ${id}`);
295
+ }
296
+
297
+ const updated = {
298
+ ...candidate,
299
+ status,
300
+ decidedAt: status === CandidateStatus.PENDING ? null : nowIso()
301
+ };
302
+ validateCandidateRecord(updated);
303
+ Object.assign(candidate, updated);
304
+ this.#save();
305
+ return cloneData(candidate);
306
+ }
307
+
308
+ enqueueOutbox(entry) {
309
+ if (entry.id && this.data.outbox.some((item) => item.id === entry.id)) {
310
+ throw new Error(`Duplicate outbox id: ${entry.id}`);
311
+ }
312
+
313
+ const row = {
314
+ id: entry.id || randomUUID(),
315
+ kind: entry.kind,
316
+ aggregateId: entry.aggregateId,
317
+ payload: cloneData(entry.payload),
318
+ status: entry.status ?? 'pending',
319
+ attempts: entry.attempts ?? 0,
320
+ nextAttemptAt: entry.nextAttemptAt ?? null,
321
+ createdAt: entry.createdAt ?? nowIso(),
322
+ sentAt: entry.sentAt ?? null,
323
+ lastError: entry.lastError ?? null
324
+ };
325
+ validateOutboxRecord(row);
326
+ this.data.outbox.push(row);
327
+ this.#save();
328
+ return cloneData(row);
329
+ }
330
+
331
+ listPendingOutbox(at = nowIso()) {
332
+ return cloneData(this.data.outbox
333
+ .filter(
334
+ (row) =>
335
+ row.status === 'pending' &&
336
+ (row.nextAttemptAt === null || row.nextAttemptAt <= at)
337
+ )
338
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt)));
339
+ }
340
+
341
+ markOutboxSent(id, sentAt = nowIso()) {
342
+ const row = this.data.outbox.find((item) => item.id === id);
343
+ if (!row) {
344
+ throw new Error(`Outbox row not found: ${id}`);
345
+ }
346
+
347
+ row.status = 'sent';
348
+ row.nextAttemptAt = null;
349
+ row.sentAt = sentAt;
350
+ row.lastError = null;
351
+ this.#save();
352
+ return cloneData(row);
353
+ }
354
+
355
+ markOutboxFailed(id, error, nextAttemptAt = null) {
356
+ const row = this.data.outbox.find((item) => item.id === id);
357
+ if (!row) {
358
+ throw new Error(`Outbox row not found: ${id}`);
359
+ }
360
+
361
+ row.status = 'pending';
362
+ row.attempts += 1;
363
+ row.nextAttemptAt = nextAttemptAt;
364
+ row.lastError = error;
365
+ this.#save();
366
+ return cloneData(row);
367
+ }
368
+
369
+ hasImport(contentHash) {
370
+ return this.data.imports.some((record) => record.contentHash === contentHash);
371
+ }
372
+
373
+ recordImport(record) {
374
+ if (this.hasImport(record.contentHash)) {
375
+ throw new Error(`Duplicate import content hash: ${record.contentHash}`);
376
+ }
377
+
378
+ const stored = cloneData(record);
379
+ this.data.imports.push(stored);
380
+ this.#save();
381
+ return cloneData(stored);
382
+ }
383
+
384
+ exportSnapshot() {
385
+ return cloneData(this.data);
386
+ }
387
+
388
+ importSnapshot(snapshot) {
389
+ this.data = normalizeSnapshot(snapshot, {
390
+ outbox: this.data.outbox,
391
+ imports: this.data.imports
392
+ });
393
+ this.#episodeIndex.rebuild(this.data.episodes);
394
+ this.#save();
395
+ }
396
+
397
+ close() {}
398
+
399
+ #load() {
400
+ return normalizeSnapshot(readJsonFile(this.filePath, {}));
401
+ }
402
+
403
+ #save() {
404
+ if (this.transactionActive) {
405
+ this.transactionDirty = true;
406
+ return;
407
+ }
408
+
409
+ try {
410
+ this.#commit();
411
+ } catch (error) {
412
+ this.data = cloneData(this.#committedData);
413
+ this.#episodeIndex.rebuild(this.data.episodes);
414
+ throw error;
415
+ }
416
+ }
417
+
418
+ #commit() {
419
+ this.#persist();
420
+ this.#committedData = cloneData(this.data);
421
+ }
422
+
423
+ #persist() {
424
+ if (this.memoryOnly) return;
425
+ writeJsonFileAtomic(this.filePath, this.data, this.jsonFileIo);
426
+ }
427
+ }
428
+
429
+ function cloneData(data) {
430
+ return JSON.parse(JSON.stringify(data));
431
+ }
432
+
433
+ function isThenable(value) {
434
+ return value !== null &&
435
+ (typeof value === 'object' || typeof value === 'function') &&
436
+ typeof value.then === 'function';
437
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { nowIso } from '../models.js';
4
+ import { validateLegacySnapshot } from './legacy-snapshot-validation.js';
5
+ import { stableJson } from './stable-json.js';
6
+
7
+ export function importJsonSnapshot(store, snapshot, sourcePath = '<memory>', options = {}) {
8
+ return applyPreparedJsonSnapshot(store, prepareJsonSnapshot(snapshot, sourcePath), options);
9
+ }
10
+
11
+ export function prepareJsonSnapshot(snapshot, sourcePath = '<memory>') {
12
+ validateSourcePath(sourcePath);
13
+ const normalized = validateLegacySnapshot(snapshot);
14
+ const canonicalSnapshot = JSON.parse(stableJson(normalized));
15
+ const contentHash = hashSnapshot(canonicalSnapshot);
16
+ return deepFreeze({ snapshot: deepFreeze(canonicalSnapshot), sourcePath, contentHash });
17
+ }
18
+
19
+ export function applyPreparedJsonSnapshot(store, prepared, { replace = false } = {}) {
20
+ const verified = verifyPreparedSnapshot(prepared);
21
+ let imported = false;
22
+
23
+ store.transaction(() => {
24
+ if (store.hasImport(verified.contentHash)) return;
25
+ if (!replace && !isEmptySnapshot(store.exportSnapshot())) {
26
+ throw new Error('Destination snapshot is not empty; pass replace: true to replace it');
27
+ }
28
+
29
+ store.importSnapshot(verified.snapshot);
30
+ store.recordImport({
31
+ contentHash: verified.contentHash,
32
+ sourcePath: verified.sourcePath,
33
+ importedAt: nowIso()
34
+ });
35
+ imported = true;
36
+ }, { mode: 'immediate' });
37
+
38
+ return { imported, contentHash: verified.contentHash };
39
+ }
40
+
41
+ function verifyPreparedSnapshot(prepared) {
42
+ if (!prepared || typeof prepared !== 'object' || Array.isArray(prepared)) {
43
+ throw new TypeError('Prepared snapshot must be an object');
44
+ }
45
+ const verified = prepareJsonSnapshot(prepared.snapshot, prepared.sourcePath);
46
+ if (prepared.contentHash !== verified.contentHash) {
47
+ throw new Error('Prepared snapshot content hash does not match its snapshot');
48
+ }
49
+ return verified;
50
+ }
51
+
52
+ function hashSnapshot(snapshot) {
53
+ return createHash('sha256').update(stableJson(snapshot)).digest('hex');
54
+ }
55
+
56
+ function validateSourcePath(sourcePath) {
57
+ if (typeof sourcePath !== 'string' || sourcePath.length === 0) {
58
+ throw new TypeError('Import source path must be a nonempty string');
59
+ }
60
+ }
61
+
62
+ function isEmptySnapshot(snapshot) {
63
+ return Object.values(snapshot).every((collection) => Array.isArray(collection) && collection.length === 0);
64
+ }
65
+
66
+ function deepFreeze(value) {
67
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
68
+ for (const child of Object.values(value)) deepFreeze(child);
69
+ return Object.freeze(value);
70
+ }
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync
9
+ } from 'node:fs';
10
+ import { basename, dirname, join } from 'node:path';
11
+
12
+ const DEFAULT_IO = Object.freeze({ mkdirSync, renameSync, rmSync, writeFileSync });
13
+
14
+ export function readJsonFile(filePath, fallback) {
15
+ if (!existsSync(filePath)) return fallback;
16
+
17
+ const raw = readFileSync(filePath, 'utf8');
18
+ return raw.trim() ? JSON.parse(raw) : fallback;
19
+ }
20
+
21
+ export function writeJsonFileAtomic(filePath, value, io = {}) {
22
+ const fileIo = { ...DEFAULT_IO, ...io };
23
+ const directory = dirname(filePath);
24
+ const tempPath = join(directory, `.${basename(filePath)}.${randomUUID()}.tmp`);
25
+
26
+ fileIo.mkdirSync(directory, { recursive: true });
27
+ try {
28
+ fileIo.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
29
+ encoding: 'utf8',
30
+ flag: 'wx'
31
+ });
32
+ fileIo.renameSync(tempPath, filePath);
33
+ } catch (error) {
34
+ try {
35
+ fileIo.rmSync(tempPath, { force: true });
36
+ } catch {
37
+ // Preserve the write or rename failure that caused cleanup.
38
+ }
39
+ throw error;
40
+ }
41
+ }
@@ -0,0 +1,152 @@
1
+ import { CandidateStatus, FactStatus, SpaceKind } from '../models.js';
2
+ import {
3
+ validateCandidateRecord,
4
+ validateFactRecord,
5
+ validateOutboxRecord
6
+ } from './record-validation.js';
7
+
8
+ export function validateLegacyRecords(snapshot) {
9
+ requireUnique(snapshot.spaces, 'space', (record) => record.id);
10
+ requireUnique(snapshot.spaces, 'space name', (record) => record.name);
11
+ requireUnique(snapshot.episodes, 'episode', (record) => record.id);
12
+ requireUnique(snapshot.facts, 'fact', (record) => record.id);
13
+ requireUnique(snapshot.candidates, 'candidate', (record) => record.id);
14
+ requireUnique(snapshot.outbox ?? [], 'outbox', (record) => record.id);
15
+ requireUnique(snapshot.imports ?? [], 'import', (record) => record.contentHash);
16
+ requireUnique(snapshot.subscriptions, 'subscription', subscriptionKey);
17
+ for (const space of snapshot.spaces) validateSpace(space);
18
+ for (const subscription of snapshot.subscriptions) validateSubscription(subscription);
19
+ for (const episode of snapshot.episodes) validateEpisode(episode);
20
+ for (const fact of snapshot.facts) validateFact(fact);
21
+ for (const candidate of snapshot.candidates) validateCandidate(candidate);
22
+ for (const entry of snapshot.outbox ?? []) validateOutbox(entry);
23
+ for (const record of snapshot.imports ?? []) {
24
+ requireStrings(record, 'import', ['contentHash', 'sourcePath', 'importedAt']);
25
+ }
26
+ validateReferences(snapshot);
27
+ }
28
+ function validateSpace(space) {
29
+ requireStrings(space, 'space', ['id', 'name', 'kind']);
30
+ if (!Object.values(SpaceKind).includes(space.kind)) {
31
+ throw new TypeError(`Invalid space kind: ${space.kind}`);
32
+ }
33
+ nullableString(space, 'description');
34
+ optionalString(space, 'createdAt');
35
+ }
36
+
37
+ function validateSubscription(subscription) {
38
+ requireStrings(subscription, 'subscription', ['personalSpaceId', 'spaceId']);
39
+ optionalString(subscription, 'mode');
40
+ optionalString(subscription, 'createdAt');
41
+ }
42
+
43
+ function validateEpisode(episode) {
44
+ requireStrings(episode, 'episode', ['id', 'spaceId', 'sourceKind', 'body']);
45
+ nullableString(episode, 'sourceUri');
46
+ optionalString(episode, 'createdAt');
47
+ }
48
+
49
+ function validateFact(fact) {
50
+ requireStrings(fact, 'fact', ['id', 'spaceId', 'subject', 'predicate', 'object', 'sourceEpisodeId']);
51
+ optionalString(fact, 'validAt');
52
+ nullableString(fact, 'invalidAt');
53
+ nullableString(fact, 'replacedByFactId');
54
+ validateFactRecord({
55
+ ...fact,
56
+ status: fact.status ?? FactStatus.CONFIRMED,
57
+ confidence: fact.confidence ?? 1,
58
+ sensitivity: fact.sensitivity ?? 'normal',
59
+ scope: fact.scope ?? 'personal'
60
+ });
61
+ }
62
+
63
+ function validateCandidate(candidate) {
64
+ requireStrings(candidate, 'candidate', ['id', 'personalSpaceId', 'episodeId', 'reason']);
65
+ nullableString(candidate, 'targetSpaceId');
66
+ optionalString(candidate, 'createdAt');
67
+ nullableString(candidate, 'decidedAt');
68
+ validateCandidateRecord({ ...candidate, status: candidate.status ?? CandidateStatus.PENDING });
69
+ }
70
+
71
+ function validateOutbox(entry) {
72
+ requireStrings(entry, 'outbox', ['id', 'kind', 'aggregateId']);
73
+ optionalString(entry, 'createdAt');
74
+ nullableString(entry, 'nextAttemptAt');
75
+ nullableString(entry, 'sentAt');
76
+ nullableString(entry, 'lastError');
77
+ validateOutboxRecord({ ...entry, status: entry.status ?? 'pending', attempts: entry.attempts ?? 0 });
78
+ }
79
+
80
+ function validateReferences(snapshot) {
81
+ const spaces = ids(snapshot.spaces);
82
+ const episodes = ids(snapshot.episodes);
83
+ const facts = ids(snapshot.facts);
84
+ for (const subscription of snapshot.subscriptions) {
85
+ requireReference(spaces, subscription.personalSpaceId, 'Subscription references missing personal space');
86
+ requireReference(spaces, subscription.spaceId, 'Subscription references missing space');
87
+ }
88
+ for (const episode of snapshot.episodes) {
89
+ requireReference(spaces, episode.spaceId, `Episode ${episode.id} references missing space`);
90
+ }
91
+ for (const fact of snapshot.facts) {
92
+ requireReference(spaces, fact.spaceId, `Fact ${fact.id} references missing space`);
93
+ requireReference(episodes, fact.sourceEpisodeId, `Fact ${fact.id} references missing episode`);
94
+ if (fact.replacedByFactId) {
95
+ requireReference(facts, fact.replacedByFactId, `Fact ${fact.id} references missing replacement fact`);
96
+ }
97
+ }
98
+ for (const candidate of snapshot.candidates) {
99
+ requireReference(spaces, candidate.personalSpaceId, `Candidate ${candidate.id} references missing personal space`);
100
+ if (candidate.targetSpaceId) {
101
+ requireReference(spaces, candidate.targetSpaceId, `Candidate ${candidate.id} references missing target space`);
102
+ }
103
+ requireReference(episodes, candidate.episodeId, `Candidate ${candidate.id} references missing episode`);
104
+ }
105
+ }
106
+
107
+ function requireUnique(records, label, identifier) {
108
+ const seen = new Set();
109
+ for (const record of records) {
110
+ const value = identifier(record);
111
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} id must be a nonempty string`);
112
+ if (seen.has(value)) {
113
+ const suffix = label === 'subscription' || label.endsWith('name')
114
+ ? `: ${value}`
115
+ : ` id: ${value}`;
116
+ throw new Error(`Duplicate ${label}${suffix}`);
117
+ }
118
+ seen.add(value);
119
+ }
120
+ }
121
+
122
+ function requireStrings(record, label, fields) {
123
+ for (const field of fields) {
124
+ if (typeof record[field] !== 'string' || record[field].length === 0) {
125
+ throw new TypeError(`${label} ${field} must be a nonempty string`);
126
+ }
127
+ }
128
+ }
129
+
130
+ function optionalString(record, field) {
131
+ if (record[field] !== undefined && typeof record[field] !== 'string') {
132
+ throw new TypeError(`${field} must be a string when provided`);
133
+ }
134
+ }
135
+
136
+ function nullableString(record, field) {
137
+ if (record[field] !== undefined && record[field] !== null && typeof record[field] !== 'string') {
138
+ throw new TypeError(`${field} must be a string or null`);
139
+ }
140
+ }
141
+
142
+ function subscriptionKey(record) {
143
+ return `${record.personalSpaceId} -> ${record.spaceId}`;
144
+ }
145
+
146
+ function requireReference(validIds, id, message) {
147
+ if (!validIds.has(id)) throw new Error(`${message}: ${id}`);
148
+ }
149
+
150
+ function ids(records) {
151
+ return new Set(records.map((record) => record.id));
152
+ }