engrm 0.1.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 (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { MemDatabase } from "./sqlite.js";
6
+
7
+ let db: MemDatabase;
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = mkdtempSync(join(tmpdir(), "engrm-vec-test-"));
12
+ db = new MemDatabase(join(tmpDir, "test.db"));
13
+ });
14
+
15
+ afterEach(() => {
16
+ db.close();
17
+ rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ // All tests conditional on sqlite-vec being available
21
+ describe("sqlite-vec integration", () => {
22
+ test("vecAvailable is true when extension loads", () => {
23
+ expect(db.vecAvailable).toBe(true);
24
+ });
25
+
26
+ test("vec_observations table exists", () => {
27
+ if (!db.vecAvailable) return;
28
+ const tables = db.db
29
+ .query<{ name: string }, []>(
30
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='vec_observations'"
31
+ )
32
+ .all();
33
+ expect(tables.length).toBe(1);
34
+ });
35
+
36
+ test("vecInsert and searchVec round-trip", () => {
37
+ if (!db.vecAvailable) return;
38
+
39
+ // Create a project + observation
40
+ const project = db.upsertProject({
41
+ canonical_id: "test/project",
42
+ name: "test",
43
+ });
44
+
45
+ const obs = db.insertObservation({
46
+ project_id: project.id,
47
+ type: "bugfix",
48
+ title: "Fix auth bug",
49
+ quality: 0.8,
50
+ user_id: "david",
51
+ device_id: "laptop",
52
+ });
53
+
54
+ // Insert embedding
55
+ const vec = new Float32Array(384);
56
+ vec[0] = 1.0;
57
+ vec[1] = 0.5;
58
+ db.vecInsert(obs.id, vec);
59
+
60
+ // Search with same vector — should find it
61
+ const results = db.searchVec(vec, project.id, ["active"], 5);
62
+ expect(results.length).toBe(1);
63
+ expect(results[0]!.observation_id).toBe(obs.id);
64
+ expect(results[0]!.distance).toBe(0); // exact match
65
+ });
66
+
67
+ test("searchVec filters by project", () => {
68
+ if (!db.vecAvailable) return;
69
+
70
+ const proj1 = db.upsertProject({ canonical_id: "p1", name: "p1" });
71
+ const proj2 = db.upsertProject({ canonical_id: "p2", name: "p2" });
72
+
73
+ const obs1 = db.insertObservation({
74
+ project_id: proj1.id,
75
+ type: "bugfix",
76
+ title: "P1 fix",
77
+ quality: 0.8,
78
+ user_id: "david",
79
+ device_id: "laptop",
80
+ });
81
+ const obs2 = db.insertObservation({
82
+ project_id: proj2.id,
83
+ type: "bugfix",
84
+ title: "P2 fix",
85
+ quality: 0.8,
86
+ user_id: "david",
87
+ device_id: "laptop",
88
+ });
89
+
90
+ const vec = new Float32Array(384);
91
+ vec[0] = 1.0;
92
+ db.vecInsert(obs1.id, vec);
93
+ db.vecInsert(obs2.id, vec);
94
+
95
+ // Search scoped to project 1
96
+ const results = db.searchVec(vec, proj1.id, ["active"], 5);
97
+ expect(results.length).toBe(1);
98
+ expect(results[0]!.observation_id).toBe(obs1.id);
99
+ });
100
+
101
+ test("searchVec excludes superseded observations", () => {
102
+ if (!db.vecAvailable) return;
103
+
104
+ const project = db.upsertProject({ canonical_id: "test/proj", name: "test" });
105
+
106
+ const obs1 = db.insertObservation({
107
+ project_id: project.id,
108
+ type: "decision",
109
+ title: "Old decision",
110
+ quality: 0.7,
111
+ user_id: "david",
112
+ device_id: "laptop",
113
+ });
114
+ const obs2 = db.insertObservation({
115
+ project_id: project.id,
116
+ type: "decision",
117
+ title: "New decision",
118
+ quality: 0.9,
119
+ user_id: "david",
120
+ device_id: "laptop",
121
+ });
122
+
123
+ const vec = new Float32Array(384);
124
+ vec[0] = 1.0;
125
+ db.vecInsert(obs1.id, vec);
126
+ db.vecInsert(obs2.id, vec);
127
+
128
+ // Supersede obs1 with obs2
129
+ db.supersedeObservation(obs1.id, obs2.id);
130
+
131
+ // Search should only return obs2
132
+ const results = db.searchVec(vec, null, ["active"], 5);
133
+ expect(results.length).toBe(1);
134
+ expect(results[0]!.observation_id).toBe(obs2.id);
135
+ });
136
+
137
+ test("vecDelete removes embedding", () => {
138
+ if (!db.vecAvailable) return;
139
+
140
+ const project = db.upsertProject({ canonical_id: "t/p", name: "t" });
141
+ const obs = db.insertObservation({
142
+ project_id: project.id,
143
+ type: "bugfix",
144
+ title: "Test",
145
+ quality: 0.8,
146
+ user_id: "u",
147
+ device_id: "d",
148
+ });
149
+
150
+ const vec = new Float32Array(384);
151
+ vec[0] = 1.0;
152
+ db.vecInsert(obs.id, vec);
153
+ expect(db.searchVec(vec, null, ["active"], 5).length).toBe(1);
154
+
155
+ db.vecDelete(obs.id);
156
+ expect(db.searchVec(vec, null, ["active"], 5).length).toBe(0);
157
+ });
158
+
159
+ test("getUnembeddedObservations returns unembedded only", () => {
160
+ if (!db.vecAvailable) return;
161
+
162
+ const project = db.upsertProject({ canonical_id: "t/p", name: "t" });
163
+
164
+ const obs1 = db.insertObservation({
165
+ project_id: project.id,
166
+ type: "bugfix",
167
+ title: "Embedded",
168
+ quality: 0.8,
169
+ user_id: "u",
170
+ device_id: "d",
171
+ });
172
+ db.insertObservation({
173
+ project_id: project.id,
174
+ type: "bugfix",
175
+ title: "Not embedded",
176
+ quality: 0.8,
177
+ user_id: "u",
178
+ device_id: "d",
179
+ });
180
+
181
+ // Embed only obs1
182
+ const vec = new Float32Array(384);
183
+ db.vecInsert(obs1.id, vec);
184
+
185
+ const unembedded = db.getUnembeddedObservations(10);
186
+ expect(unembedded.length).toBe(1);
187
+ expect(unembedded[0]!.title).toBe("Not embedded");
188
+
189
+ expect(db.getUnembeddedCount()).toBe(1);
190
+ });
191
+
192
+ test("searchVec returns empty when vecAvailable but no embeddings", () => {
193
+ if (!db.vecAvailable) return;
194
+ const vec = new Float32Array(384);
195
+ const results = db.searchVec(vec, null, ["active"], 5);
196
+ expect(results.length).toBe(0);
197
+ });
198
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { getApiKey, getBaseUrl, buildSourceId, parseSourceId } from "./auth.js";
3
+ import type { Config } from "../config.js";
4
+
5
+ function makeConfig(overrides: Partial<Config> = {}): Config {
6
+ return {
7
+ candengo_url: overrides.candengo_url ?? "https://candengo.com",
8
+ candengo_api_key: overrides.candengo_api_key ?? "cvk_test123",
9
+ site_id: overrides.site_id ?? "test-site",
10
+ namespace: overrides.namespace ?? "dev-memory",
11
+ user_id: overrides.user_id ?? "david",
12
+ device_id: overrides.device_id ?? "laptop-abc",
13
+ user_email: "",
14
+ teams: [],
15
+ sync: { enabled: true, interval_seconds: 30, batch_size: 50 },
16
+ search: { default_limit: 10, local_boost: 1.2, scope: "all" },
17
+ scrubbing: { enabled: true, custom_patterns: [], default_sensitivity: "shared" },
18
+ };
19
+ }
20
+
21
+ afterEach(() => {
22
+ delete process.env.ENGRM_TOKEN;
23
+ });
24
+
25
+ describe("getApiKey", () => {
26
+ test("returns env var when set", () => {
27
+ process.env.ENGRM_TOKEN = "cvk_from_env";
28
+ expect(getApiKey(makeConfig())).toBe("cvk_from_env");
29
+ });
30
+
31
+ test("falls back to config when env var not set", () => {
32
+ expect(getApiKey(makeConfig())).toBe("cvk_test123");
33
+ });
34
+
35
+ test("returns null when both empty", () => {
36
+ expect(getApiKey(makeConfig({ candengo_api_key: "" }))).toBeNull();
37
+ });
38
+
39
+ test("ignores env var without cvk_ prefix", () => {
40
+ process.env.ENGRM_TOKEN = "not_a_valid_key";
41
+ expect(getApiKey(makeConfig())).toBe("cvk_test123");
42
+ });
43
+ });
44
+
45
+ describe("getBaseUrl", () => {
46
+ test("returns config URL", () => {
47
+ expect(getBaseUrl(makeConfig())).toBe("https://candengo.com");
48
+ });
49
+
50
+ test("returns null for empty URL", () => {
51
+ expect(getBaseUrl(makeConfig({ candengo_url: "" }))).toBeNull();
52
+ });
53
+ });
54
+
55
+ describe("buildSourceId", () => {
56
+ test("produces correct format", () => {
57
+ const config = makeConfig({ user_id: "david", device_id: "laptop-abc" });
58
+ expect(buildSourceId(config, 42)).toBe("david-laptop-abc-obs-42");
59
+ });
60
+ });
61
+
62
+ describe("parseSourceId", () => {
63
+ test("parses valid source ID", () => {
64
+ const result = parseSourceId("david-laptop-abc-obs-42");
65
+ expect(result).toEqual({
66
+ userId: "david",
67
+ deviceId: "laptop-abc",
68
+ localId: 42,
69
+ });
70
+ });
71
+
72
+ test("returns null for invalid format", () => {
73
+ expect(parseSourceId("invalid")).toBeNull();
74
+ expect(parseSourceId("")).toBeNull();
75
+ });
76
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sync authentication utilities.
3
+ *
4
+ * Resolves API credentials from environment variables or config.
5
+ * Environment variable takes precedence for CI/CD use.
6
+ */
7
+
8
+ import type { Config } from "../config.js";
9
+
10
+ /**
11
+ * Get API key for Candengo Vector.
12
+ * Priority: ENGRM_TOKEN env var → config.candengo_api_key
13
+ */
14
+ export function getApiKey(config: Config): string | null {
15
+ const envKey = process.env.ENGRM_TOKEN;
16
+ if (envKey && envKey.startsWith("cvk_")) return envKey;
17
+ if (config.candengo_api_key && config.candengo_api_key.length > 0) {
18
+ return config.candengo_api_key;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * Get base URL for Candengo Vector API.
25
+ */
26
+ export function getBaseUrl(config: Config): string | null {
27
+ if (config.candengo_url && config.candengo_url.length > 0) {
28
+ return config.candengo_url;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Build a globally unique source ID for a record.
35
+ * Format: {user_id}-{device_id}-{type}-{local_id}
36
+ * Default type is "obs" for backwards compatibility.
37
+ */
38
+ export function buildSourceId(config: Config, localId: number, type: string = "obs"): string {
39
+ return `${config.user_id}-${config.device_id}-${type}-${localId}`;
40
+ }
41
+
42
+ /**
43
+ * Parse a source ID back into its components.
44
+ * Returns null if the format doesn't match.
45
+ */
46
+ export function parseSourceId(
47
+ sourceId: string
48
+ ): { userId: string; deviceId: string; localId: number } | null {
49
+ // Format: {user_id}-{device_id}-obs-{local_id}
50
+ // Split on "-obs-" which is our guaranteed delimiter
51
+ const obsIndex = sourceId.lastIndexOf("-obs-");
52
+ if (obsIndex === -1) return null;
53
+
54
+ const prefix = sourceId.slice(0, obsIndex);
55
+ const localIdStr = sourceId.slice(obsIndex + 5); // skip "-obs-"
56
+ const localId = parseInt(localIdStr, 10);
57
+ if (isNaN(localId)) return null;
58
+
59
+ // Split prefix on first "-" to get userId and deviceId
60
+ const firstDash = prefix.indexOf("-");
61
+ if (firstDash === -1) return null;
62
+
63
+ return {
64
+ userId: prefix.slice(0, firstDash),
65
+ deviceId: prefix.slice(firstDash + 1),
66
+ localId,
67
+ };
68
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Candengo Vector REST client.
3
+ *
4
+ * Wraps the Vector API for ingest, search, and delete operations.
5
+ * Uses Bun's built-in fetch — no external HTTP dependencies.
6
+ */
7
+
8
+ import type { Config } from "../config.js";
9
+ import { getApiKey, getBaseUrl } from "./auth.js";
10
+
11
+ // --- Types ---
12
+
13
+ export interface VectorDocument {
14
+ site_id: string;
15
+ namespace: string;
16
+ source_type: string;
17
+ source_id: string;
18
+ content: string;
19
+ metadata: Record<string, unknown>;
20
+ }
21
+
22
+ export interface VectorSearchResult {
23
+ source_id: string;
24
+ content: string;
25
+ score: number;
26
+ metadata: Record<string, unknown>;
27
+ }
28
+
29
+ export interface VectorSearchResponse {
30
+ results: VectorSearchResult[];
31
+ total?: number;
32
+ }
33
+
34
+ export interface VectorChangeFeedResponse {
35
+ changes: VectorSearchResult[];
36
+ cursor: string;
37
+ has_more: boolean;
38
+ }
39
+
40
+ // --- Client ---
41
+
42
+ export class VectorClient {
43
+ private readonly baseUrl: string;
44
+ private readonly apiKey: string;
45
+ readonly siteId: string;
46
+ readonly namespace: string;
47
+
48
+ constructor(config: Config) {
49
+ const baseUrl = getBaseUrl(config);
50
+ const apiKey = getApiKey(config);
51
+
52
+ if (!baseUrl || !apiKey) {
53
+ throw new Error(
54
+ "VectorClient requires candengo_url and candengo_api_key"
55
+ );
56
+ }
57
+
58
+ this.baseUrl = baseUrl.replace(/\/$/, ""); // strip trailing slash
59
+ this.apiKey = apiKey;
60
+ this.siteId = config.site_id;
61
+ this.namespace = config.namespace;
62
+ }
63
+
64
+ /**
65
+ * Check if the client has valid configuration.
66
+ */
67
+ static isConfigured(config: Config): boolean {
68
+ return getApiKey(config) !== null && getBaseUrl(config) !== null;
69
+ }
70
+
71
+ /**
72
+ * Ingest a single document.
73
+ */
74
+ async ingest(doc: VectorDocument): Promise<void> {
75
+ await this.request("POST", "/v1/ingest", doc);
76
+ }
77
+
78
+ /**
79
+ * Batch ingest multiple documents.
80
+ */
81
+ async batchIngest(docs: VectorDocument[]): Promise<void> {
82
+ if (docs.length === 0) return;
83
+ await this.request("POST", "/v1/ingest/batch", { documents: docs });
84
+ }
85
+
86
+ /**
87
+ * Semantic search with optional metadata filters.
88
+ */
89
+ async search(
90
+ query: string,
91
+ metadataFilter?: Record<string, string>,
92
+ limit: number = 10
93
+ ): Promise<VectorSearchResponse> {
94
+ const body: Record<string, unknown> = { query: query, limit: limit };
95
+ if (metadataFilter) {
96
+ body.metadata_filter = metadataFilter;
97
+ }
98
+ return this.request("POST", "/v1/search", body);
99
+ }
100
+
101
+ /**
102
+ * Delete documents by source IDs.
103
+ */
104
+ async deleteBySourceIds(sourceIds: string[]): Promise<void> {
105
+ if (sourceIds.length === 0) return;
106
+ await this.request("POST", "/v1/documents/delete", {
107
+ source_ids: sourceIds,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Pull changes from the sync change feed.
113
+ */
114
+ async pullChanges(
115
+ cursor?: string,
116
+ limit: number = 50
117
+ ): Promise<VectorChangeFeedResponse> {
118
+ const params = new URLSearchParams();
119
+ if (cursor) params.set("cursor", cursor);
120
+ params.set("namespace", this.namespace);
121
+ params.set("limit", String(limit));
122
+ return this.request("GET", `/v1/sync/changes?${params.toString()}`);
123
+ }
124
+
125
+ /**
126
+ * Health check.
127
+ */
128
+ async health(): Promise<boolean> {
129
+ try {
130
+ await this.request("GET", "/health");
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ // --- Internal ---
138
+
139
+ private async request<T>(
140
+ method: string,
141
+ path: string,
142
+ body?: unknown
143
+ ): Promise<T> {
144
+ const url = `${this.baseUrl}${path}`;
145
+ const headers: Record<string, string> = {
146
+ Authorization: `Bearer ${this.apiKey}`,
147
+ "Content-Type": "application/json",
148
+ };
149
+
150
+ const init: RequestInit = { method, headers };
151
+ if (body && method !== "GET") {
152
+ init.body = JSON.stringify(body);
153
+ }
154
+
155
+ const response = await fetch(url, init);
156
+
157
+ if (!response.ok) {
158
+ const text = await response.text().catch(() => "");
159
+ throw new VectorApiError(response.status, text, path);
160
+ }
161
+
162
+ // Some endpoints (DELETE, ingest) may return 204 with no body
163
+ if (
164
+ response.status === 204 ||
165
+ response.headers.get("content-length") === "0"
166
+ ) {
167
+ return undefined as T;
168
+ }
169
+
170
+ return response.json() as Promise<T>;
171
+ }
172
+ }
173
+
174
+ export class VectorApiError extends Error {
175
+ constructor(
176
+ readonly status: number,
177
+ readonly body: string,
178
+ readonly path: string
179
+ ) {
180
+ super(`Vector API error ${status} on ${path}: ${body}`);
181
+ this.name = "VectorApiError";
182
+ }
183
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { MemDatabase } from "../storage/sqlite.js";
6
+ import type { Config } from "../config.js";
7
+ import { SyncEngine } from "./engine.js";
8
+
9
+ let db: MemDatabase;
10
+ let tmpDir: string;
11
+
12
+ function makeConfig(overrides: Partial<Config> = {}): Config {
13
+ return {
14
+ candengo_url: overrides.candengo_url ?? "",
15
+ candengo_api_key: overrides.candengo_api_key ?? "",
16
+ site_id: "test-site",
17
+ namespace: "dev-memory",
18
+ user_id: "david",
19
+ device_id: "laptop-abc",
20
+ user_email: "",
21
+ teams: [],
22
+ sync: { enabled: true, interval_seconds: 30, batch_size: 50 },
23
+ search: { default_limit: 10, local_boost: 1.2, scope: "all" },
24
+ scrubbing: { enabled: true, custom_patterns: [], default_sensitivity: "shared" },
25
+ };
26
+ }
27
+
28
+ beforeEach(() => {
29
+ tmpDir = mkdtempSync(join(tmpdir(), "candengo-engine-test-"));
30
+ db = new MemDatabase(join(tmpDir, "test.db"));
31
+ });
32
+
33
+ afterEach(() => {
34
+ db.close();
35
+ rmSync(tmpDir, { recursive: true, force: true });
36
+ });
37
+
38
+ describe("SyncEngine", () => {
39
+ test("is not configured when no API key", () => {
40
+ const engine = new SyncEngine(db, makeConfig());
41
+ expect(engine.isConfigured()).toBe(false);
42
+ });
43
+
44
+ test("is configured when API key and URL provided", () => {
45
+ const engine = new SyncEngine(
46
+ db,
47
+ makeConfig({
48
+ candengo_url: "https://candengo.com",
49
+ candengo_api_key: "cvk_test123",
50
+ })
51
+ );
52
+ expect(engine.isConfigured()).toBe(true);
53
+ });
54
+
55
+ test("start is no-op when not configured", () => {
56
+ const engine = new SyncEngine(db, makeConfig());
57
+ engine.start();
58
+ expect(engine.isRunning()).toBe(false);
59
+ engine.stop(); // should not throw
60
+ });
61
+
62
+ test("start and stop work cleanly when configured", () => {
63
+ const engine = new SyncEngine(
64
+ db,
65
+ makeConfig({
66
+ candengo_url: "https://candengo.com",
67
+ candengo_api_key: "cvk_test123",
68
+ })
69
+ );
70
+ engine.start();
71
+ expect(engine.isRunning()).toBe(true);
72
+ engine.stop();
73
+ expect(engine.isRunning()).toBe(false);
74
+ });
75
+
76
+ test("stop is idempotent", () => {
77
+ const engine = new SyncEngine(db, makeConfig());
78
+ engine.stop();
79
+ engine.stop();
80
+ // Should not throw
81
+ });
82
+
83
+ test("start with sync disabled is no-op", () => {
84
+ const config = makeConfig({
85
+ candengo_url: "https://candengo.com",
86
+ candengo_api_key: "cvk_test123",
87
+ });
88
+ config.sync.enabled = false;
89
+ const engine = new SyncEngine(db, config);
90
+ engine.start();
91
+ expect(engine.isRunning()).toBe(false);
92
+ engine.stop();
93
+ });
94
+ });