@bojanrajkovic/mcp-paprika 1.0.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.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/cache/disk-cache.d.ts +21 -0
- package/dist/cache/disk-cache.js +252 -0
- package/dist/cache/recipe-store.d.ts +33 -0
- package/dist/cache/recipe-store.js +189 -0
- package/dist/features/discover-feature.d.ts +5 -0
- package/dist/features/discover-feature.js +39 -0
- package/dist/features/embedding-errors.d.ts +26 -0
- package/dist/features/embedding-errors.js +34 -0
- package/dist/features/embeddings.d.ts +70 -0
- package/dist/features/embeddings.js +186 -0
- package/dist/features/vector-store-errors.d.ts +12 -0
- package/dist/features/vector-store-errors.js +15 -0
- package/dist/features/vector-store.d.ts +63 -0
- package/dist/features/vector-store.js +202 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +100 -0
- package/dist/paprika/client.d.ts +27 -0
- package/dist/paprika/client.js +183 -0
- package/dist/paprika/errors.d.ts +37 -0
- package/dist/paprika/errors.js +48 -0
- package/dist/paprika/sync.d.ts +27 -0
- package/dist/paprika/sync.js +150 -0
- package/dist/paprika/types.d.ts +324 -0
- package/dist/paprika/types.js +116 -0
- package/dist/resources/recipes.d.ts +3 -0
- package/dist/resources/recipes.js +34 -0
- package/dist/tools/categories.d.ts +3 -0
- package/dist/tools/categories.js +38 -0
- package/dist/tools/create.d.ts +3 -0
- package/dist/tools/create.js +79 -0
- package/dist/tools/delete.d.ts +3 -0
- package/dist/tools/delete.js +33 -0
- package/dist/tools/discover.d.ts +4 -0
- package/dist/tools/discover.js +60 -0
- package/dist/tools/filter.d.ts +3 -0
- package/dist/tools/filter.js +101 -0
- package/dist/tools/helpers.d.ts +31 -0
- package/dist/tools/helpers.js +112 -0
- package/dist/tools/list.d.ts +3 -0
- package/dist/tools/list.js +34 -0
- package/dist/tools/read.d.ts +3 -0
- package/dist/tools/read.js +42 -0
- package/dist/tools/search.d.ts +3 -0
- package/dist/tools/search.js +46 -0
- package/dist/tools/update.d.ts +3 -0
- package/dist/tools/update.js +77 -0
- package/dist/types/server-context.d.ts +10 -0
- package/dist/types/server-context.js +1 -0
- package/dist/utils/config.d.ts +115 -0
- package/dist/utils/config.js +197 -0
- package/dist/utils/duration.d.ts +10 -0
- package/dist/utils/duration.js +86 -0
- package/dist/utils/xdg.d.ts +5 -0
- package/dist/utils/xdg.js +17 -0
- package/package.json +64 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed HTTP client for the Paprika Cloud Sync API.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates authentication against the v1 login endpoint
|
|
5
|
+
* and resilient request execution against the v2 data endpoint.
|
|
6
|
+
*
|
|
7
|
+
* Provides recipe and category read methods, plus write methods
|
|
8
|
+
* added in P1-U07 (saveRecipe, deleteRecipe, notifySync).
|
|
9
|
+
*/
|
|
10
|
+
import { gzipSync } from "node:zlib";
|
|
11
|
+
import { ExponentialBackoff, ConsecutiveBreaker, bulkhead, retry, circuitBreaker, handleType, wrap, BrokenCircuitError, } from "cockatiel";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { AuthResponseSchema, CategorySchema, RecipeEntrySchema, RecipeSchema } from "./types.js";
|
|
14
|
+
import { PaprikaAuthError, PaprikaAPIError } from "./errors.js";
|
|
15
|
+
const AUTH_URL = "https://paprikaapp.com/api/v1/account/login/";
|
|
16
|
+
const API_BASE = "https://paprikaapp.com/api/v2/sync";
|
|
17
|
+
class TransientHTTPError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status) {
|
|
20
|
+
super(`Transient HTTP error (${status.toString()})`);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = "TransientHTTPError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class TokenExpiredError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super("Token expired");
|
|
28
|
+
this.name = "TokenExpiredError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503]);
|
|
32
|
+
const retryPolicy = retry(handleType(TransientHTTPError), {
|
|
33
|
+
maxAttempts: 3,
|
|
34
|
+
backoff: new ExponentialBackoff({
|
|
35
|
+
initialDelay: 500,
|
|
36
|
+
maxDelay: 10_000,
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
const breakerPolicy = circuitBreaker(handleType(TransientHTTPError), {
|
|
40
|
+
halfOpenAfter: 30_000,
|
|
41
|
+
breaker: new ConsecutiveBreaker(5),
|
|
42
|
+
});
|
|
43
|
+
const resilience = wrap(retryPolicy, breakerPolicy);
|
|
44
|
+
function recipeToApiPayload(recipe) {
|
|
45
|
+
return {
|
|
46
|
+
uid: recipe.uid,
|
|
47
|
+
hash: recipe.hash,
|
|
48
|
+
name: recipe.name,
|
|
49
|
+
categories: recipe.categories,
|
|
50
|
+
ingredients: recipe.ingredients,
|
|
51
|
+
directions: recipe.directions,
|
|
52
|
+
description: recipe.description,
|
|
53
|
+
notes: recipe.notes,
|
|
54
|
+
prep_time: recipe.prepTime,
|
|
55
|
+
cook_time: recipe.cookTime,
|
|
56
|
+
total_time: recipe.totalTime,
|
|
57
|
+
servings: recipe.servings,
|
|
58
|
+
difficulty: recipe.difficulty,
|
|
59
|
+
rating: recipe.rating,
|
|
60
|
+
created: recipe.created,
|
|
61
|
+
image_url: recipe.imageUrl,
|
|
62
|
+
photo: recipe.photo,
|
|
63
|
+
photo_hash: recipe.photoHash,
|
|
64
|
+
photo_large: recipe.photoLarge,
|
|
65
|
+
photo_url: recipe.photoUrl,
|
|
66
|
+
source: recipe.source,
|
|
67
|
+
source_url: recipe.sourceUrl,
|
|
68
|
+
on_favorites: recipe.onFavorites,
|
|
69
|
+
in_trash: recipe.inTrash,
|
|
70
|
+
is_pinned: recipe.isPinned,
|
|
71
|
+
on_grocery_list: recipe.onGroceryList,
|
|
72
|
+
scale: recipe.scale,
|
|
73
|
+
nutritional_info: recipe.nutritionalInfo,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export class PaprikaClient {
|
|
77
|
+
email;
|
|
78
|
+
password;
|
|
79
|
+
token = null;
|
|
80
|
+
_recipesBulkhead = bulkhead(5, Number.MAX_SAFE_INTEGER);
|
|
81
|
+
constructor(email, password) {
|
|
82
|
+
this.email = email;
|
|
83
|
+
this.password = password;
|
|
84
|
+
}
|
|
85
|
+
async authenticate() {
|
|
86
|
+
const response = await fetch(AUTH_URL, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
body: new URLSearchParams({ email: this.email, password: this.password }),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new PaprikaAuthError(`Authentication failed (HTTP ${response.status.toString()})`);
|
|
92
|
+
}
|
|
93
|
+
const json = await response.json();
|
|
94
|
+
const data = AuthResponseSchema.parse(json);
|
|
95
|
+
this.token = data.result.token;
|
|
96
|
+
}
|
|
97
|
+
async listRecipes() {
|
|
98
|
+
return this.request("GET", `${API_BASE}/recipes/`, z.array(RecipeEntrySchema));
|
|
99
|
+
}
|
|
100
|
+
async getRecipe(uid) {
|
|
101
|
+
return this.request("GET", `${API_BASE}/recipe/${uid}/`, RecipeSchema);
|
|
102
|
+
}
|
|
103
|
+
async getRecipes(uids) {
|
|
104
|
+
return Promise.all(uids.map((uid) => this._recipesBulkhead.execute(() => this.getRecipe(uid))));
|
|
105
|
+
}
|
|
106
|
+
async listCategories() {
|
|
107
|
+
return this.request("GET", `${API_BASE}/categories/`, z.array(CategorySchema));
|
|
108
|
+
}
|
|
109
|
+
async saveRecipe(recipe) {
|
|
110
|
+
const formData = this.buildRecipeFormData(recipe);
|
|
111
|
+
await this.request("POST", `${API_BASE}/recipe/${recipe.uid}/`, z.boolean(), formData);
|
|
112
|
+
return recipe;
|
|
113
|
+
}
|
|
114
|
+
async notifySync() {
|
|
115
|
+
await this.request("POST", `${API_BASE}/notify/`, z.unknown());
|
|
116
|
+
}
|
|
117
|
+
async deleteRecipe(uid) {
|
|
118
|
+
const recipe = await this.getRecipe(uid);
|
|
119
|
+
await this.saveRecipe({ ...recipe, inTrash: true });
|
|
120
|
+
await this.notifySync();
|
|
121
|
+
}
|
|
122
|
+
buildRecipeFormData(recipe) {
|
|
123
|
+
const payload = recipeToApiPayload(recipe);
|
|
124
|
+
const json = JSON.stringify(payload);
|
|
125
|
+
const compressed = gzipSync(json);
|
|
126
|
+
const blob = new Blob([compressed]);
|
|
127
|
+
const formData = new FormData();
|
|
128
|
+
formData.append("data", blob, "data.gz");
|
|
129
|
+
return formData;
|
|
130
|
+
}
|
|
131
|
+
async request(method, url, schema, body) {
|
|
132
|
+
const execute = async () => {
|
|
133
|
+
const headers = {};
|
|
134
|
+
if (this.token) {
|
|
135
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
136
|
+
}
|
|
137
|
+
const fetchInit = { method, headers };
|
|
138
|
+
if (body !== undefined) {
|
|
139
|
+
fetchInit.body = body;
|
|
140
|
+
}
|
|
141
|
+
const response = await fetch(url, fetchInit);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
if (RETRYABLE_STATUSES.has(response.status)) {
|
|
144
|
+
throw new TransientHTTPError(response.status);
|
|
145
|
+
}
|
|
146
|
+
if (response.status === 401) {
|
|
147
|
+
throw new TokenExpiredError();
|
|
148
|
+
}
|
|
149
|
+
throw new PaprikaAPIError("Request failed", response.status, url);
|
|
150
|
+
}
|
|
151
|
+
const json = await response.json();
|
|
152
|
+
const envelope = z.object({ result: schema }).parse(json);
|
|
153
|
+
return envelope.result;
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
return await resilience.execute(execute);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error instanceof BrokenCircuitError) {
|
|
160
|
+
throw new PaprikaAPIError("Service unavailable (circuit open)", 503, url);
|
|
161
|
+
}
|
|
162
|
+
if (error instanceof TokenExpiredError) {
|
|
163
|
+
if (!this.token) {
|
|
164
|
+
throw new PaprikaAuthError("Authentication required (HTTP 401)");
|
|
165
|
+
}
|
|
166
|
+
await this.authenticate();
|
|
167
|
+
try {
|
|
168
|
+
return await resilience.execute(execute);
|
|
169
|
+
}
|
|
170
|
+
catch (retryError) {
|
|
171
|
+
if (retryError instanceof TokenExpiredError) {
|
|
172
|
+
throw new PaprikaAuthError("Authentication failed after re-auth (HTTP 401)");
|
|
173
|
+
}
|
|
174
|
+
if (retryError instanceof BrokenCircuitError) {
|
|
175
|
+
throw new PaprikaAPIError("Service unavailable (circuit open)", 503, url);
|
|
176
|
+
}
|
|
177
|
+
throw retryError;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class hierarchy for Paprika API operations.
|
|
3
|
+
*
|
|
4
|
+
* Three-class structure:
|
|
5
|
+
* - PaprikaError: base class for all Paprika-related errors
|
|
6
|
+
* - PaprikaAuthError: authentication failures (extends PaprikaError)
|
|
7
|
+
* - PaprikaAPIError: HTTP errors with status and endpoint (extends PaprikaError)
|
|
8
|
+
*
|
|
9
|
+
* All classes support ES2024 ErrorOptions for cause chaining.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Base error class for all Paprika-related operations.
|
|
13
|
+
* Extends the built-in Error class with proper name assignment.
|
|
14
|
+
*/
|
|
15
|
+
export declare class PaprikaError extends Error {
|
|
16
|
+
constructor(message: string, options?: ErrorOptions);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Error thrown when authentication fails.
|
|
20
|
+
* Indicates that the provided credentials are invalid or expired.
|
|
21
|
+
* Authentication failures are unrecoverable and typically require user intervention.
|
|
22
|
+
*/
|
|
23
|
+
export declare class PaprikaAuthError extends PaprikaError {
|
|
24
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Error thrown when an HTTP request to the Paprika API fails.
|
|
28
|
+
* Captures the HTTP status code and endpoint for debugging.
|
|
29
|
+
*
|
|
30
|
+
* The error message is formatted as: "message (HTTP status from endpoint)"
|
|
31
|
+
* Example: "Not found (HTTP 404 from /api/v2/sync/recipe/abc/)"
|
|
32
|
+
*/
|
|
33
|
+
export declare class PaprikaAPIError extends PaprikaError {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
readonly endpoint: string;
|
|
36
|
+
constructor(message: string, status: number, endpoint: string, options?: ErrorOptions);
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class hierarchy for Paprika API operations.
|
|
3
|
+
*
|
|
4
|
+
* Three-class structure:
|
|
5
|
+
* - PaprikaError: base class for all Paprika-related errors
|
|
6
|
+
* - PaprikaAuthError: authentication failures (extends PaprikaError)
|
|
7
|
+
* - PaprikaAPIError: HTTP errors with status and endpoint (extends PaprikaError)
|
|
8
|
+
*
|
|
9
|
+
* All classes support ES2024 ErrorOptions for cause chaining.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Base error class for all Paprika-related operations.
|
|
13
|
+
* Extends the built-in Error class with proper name assignment.
|
|
14
|
+
*/
|
|
15
|
+
export class PaprikaError extends Error {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = "PaprikaError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Error thrown when authentication fails.
|
|
23
|
+
* Indicates that the provided credentials are invalid or expired.
|
|
24
|
+
* Authentication failures are unrecoverable and typically require user intervention.
|
|
25
|
+
*/
|
|
26
|
+
export class PaprikaAuthError extends PaprikaError {
|
|
27
|
+
constructor(message = "Authentication failed", options) {
|
|
28
|
+
super(message, options);
|
|
29
|
+
this.name = "PaprikaAuthError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Error thrown when an HTTP request to the Paprika API fails.
|
|
34
|
+
* Captures the HTTP status code and endpoint for debugging.
|
|
35
|
+
*
|
|
36
|
+
* The error message is formatted as: "message (HTTP status from endpoint)"
|
|
37
|
+
* Example: "Not found (HTTP 404 from /api/v2/sync/recipe/abc/)"
|
|
38
|
+
*/
|
|
39
|
+
export class PaprikaAPIError extends PaprikaError {
|
|
40
|
+
status;
|
|
41
|
+
endpoint;
|
|
42
|
+
constructor(message, status, endpoint, options) {
|
|
43
|
+
super(`${message} (HTTP ${status} from ${endpoint})`, options);
|
|
44
|
+
this.name = "PaprikaAPIError";
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.endpoint = endpoint;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ServerContext } from "../types/server-context.js";
|
|
2
|
+
import type { SyncResult } from "./types.js";
|
|
3
|
+
type SyncEvents = {
|
|
4
|
+
"sync:complete": SyncResult;
|
|
5
|
+
"sync:error": Error;
|
|
6
|
+
};
|
|
7
|
+
type SyncEventEmitter = {
|
|
8
|
+
on<K extends keyof SyncEvents>(event: K, handler: (data: SyncEvents[K]) => void): void;
|
|
9
|
+
off<K extends keyof SyncEvents>(event: K, handler?: (data: SyncEvents[K]) => void): void;
|
|
10
|
+
emit<K extends keyof SyncEvents>(event: K, data: SyncEvents[K]): void;
|
|
11
|
+
all: Map<keyof SyncEvents, Array<(data: SyncEvents[keyof SyncEvents]) => void>>;
|
|
12
|
+
};
|
|
13
|
+
export declare class SyncEngine {
|
|
14
|
+
private readonly _context;
|
|
15
|
+
private readonly _intervalMs;
|
|
16
|
+
private readonly _events;
|
|
17
|
+
private readonly _eventsView;
|
|
18
|
+
private _ac;
|
|
19
|
+
constructor(context: ServerContext, intervalMs: number);
|
|
20
|
+
get events(): Pick<SyncEventEmitter, "on" | "off">;
|
|
21
|
+
start(): void;
|
|
22
|
+
stop(): void;
|
|
23
|
+
syncOnce(): Promise<void>;
|
|
24
|
+
private static _log;
|
|
25
|
+
private _loop;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { scheduler } from "node:timers/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
// Use CommonJS require to work around TypeScript ESM resolution issues with mitt
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const mittFactory = require("mitt");
|
|
6
|
+
export class SyncEngine {
|
|
7
|
+
_context;
|
|
8
|
+
_intervalMs;
|
|
9
|
+
_events;
|
|
10
|
+
_eventsView;
|
|
11
|
+
_ac = null;
|
|
12
|
+
constructor(context, intervalMs) {
|
|
13
|
+
this._context = context;
|
|
14
|
+
this._intervalMs = intervalMs;
|
|
15
|
+
// CJS require returns unknown; mitt's default export is a factory function that returns the emitter
|
|
16
|
+
this._events = mittFactory();
|
|
17
|
+
this._eventsView = {
|
|
18
|
+
on: this._events.on.bind(this._events),
|
|
19
|
+
off: this._events.off.bind(this._events),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
get events() {
|
|
23
|
+
return this._eventsView;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
if (this._ac !== null) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this._ac = new AbortController();
|
|
30
|
+
void this._loop().catch(() => { });
|
|
31
|
+
}
|
|
32
|
+
stop() {
|
|
33
|
+
if (this._ac === null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this._ac.abort();
|
|
37
|
+
this._ac = null;
|
|
38
|
+
}
|
|
39
|
+
async syncOnce() {
|
|
40
|
+
try {
|
|
41
|
+
// 1. Recipe sync path
|
|
42
|
+
SyncEngine._log("Fetching recipe list...");
|
|
43
|
+
const entries = await this._context.client.listRecipes();
|
|
44
|
+
SyncEngine._log(`Got ${entries.length} recipe entries.`);
|
|
45
|
+
const diff = this._context.cache.diffRecipes(entries);
|
|
46
|
+
SyncEngine._log(`Recipe diff: ${diff.added.length} added, ${diff.changed.length} changed, ${diff.removed.length} removed.`);
|
|
47
|
+
// Compute UIDs to fetch
|
|
48
|
+
const uidsToFetch = [...diff.added, ...diff.changed];
|
|
49
|
+
// Fetch recipes if any exist
|
|
50
|
+
let fetchedRecipes = [];
|
|
51
|
+
if (uidsToFetch.length > 0) {
|
|
52
|
+
SyncEngine._log(`Fetching ${uidsToFetch.length} recipes...`);
|
|
53
|
+
fetchedRecipes = await this._context.client.getRecipes(uidsToFetch);
|
|
54
|
+
SyncEngine._log(`Fetched ${fetchedRecipes.length} recipes.`);
|
|
55
|
+
}
|
|
56
|
+
// Write fetched recipes to cache and store
|
|
57
|
+
for (const recipe of fetchedRecipes) {
|
|
58
|
+
this._context.cache.putRecipe(recipe, recipe.hash);
|
|
59
|
+
this._context.store.set(recipe);
|
|
60
|
+
}
|
|
61
|
+
// Remove deleted recipes (async, use Promise.all for concurrency)
|
|
62
|
+
await Promise.all(diff.removed.map((uid) => this._context.cache.removeRecipe(uid)));
|
|
63
|
+
for (const uid of diff.removed) {
|
|
64
|
+
this._context.store.delete(uid);
|
|
65
|
+
}
|
|
66
|
+
// 2. Category sync path (replace-all)
|
|
67
|
+
SyncEngine._log("Fetching categories...");
|
|
68
|
+
const categories = await this._context.client.listCategories();
|
|
69
|
+
SyncEngine._log(`Got ${categories.length} categories.`);
|
|
70
|
+
this._context.store.setCategories(categories);
|
|
71
|
+
for (const category of categories) {
|
|
72
|
+
this._context.cache.putCategory(category, category.uid);
|
|
73
|
+
}
|
|
74
|
+
// 3. Finalization
|
|
75
|
+
SyncEngine._log("Flushing cache to disk...");
|
|
76
|
+
await this._context.cache.flush();
|
|
77
|
+
// Determine if recipe changes exist
|
|
78
|
+
const hasChanges = diff.added.length > 0 || diff.changed.length > 0 || diff.removed.length > 0;
|
|
79
|
+
// Send resource notification if changes exist
|
|
80
|
+
if (hasChanges) {
|
|
81
|
+
this._context.server.sendResourceListChanged();
|
|
82
|
+
}
|
|
83
|
+
// Partition fetched recipes: added vs updated
|
|
84
|
+
const addedSet = new Set(diff.added);
|
|
85
|
+
const addedRecipes = fetchedRecipes.filter((r) => addedSet.has(r.uid));
|
|
86
|
+
const updatedRecipes = fetchedRecipes.filter((r) => !addedSet.has(r.uid));
|
|
87
|
+
// Build and emit SyncResult
|
|
88
|
+
const result = {
|
|
89
|
+
added: addedRecipes,
|
|
90
|
+
updated: updatedRecipes,
|
|
91
|
+
removedUids: diff.removed,
|
|
92
|
+
};
|
|
93
|
+
this._events.emit("sync:complete", result);
|
|
94
|
+
SyncEngine._log(`Sync complete: ${addedRecipes.length} added, ${updatedRecipes.length} updated, ${diff.removed.length} removed.`);
|
|
95
|
+
// Log success via MCP
|
|
96
|
+
try {
|
|
97
|
+
await this._context.server.sendLoggingMessage({
|
|
98
|
+
level: "info",
|
|
99
|
+
data: `Sync complete: ${addedRecipes.length} added, ${updatedRecipes.length} updated, ${diff.removed.length} removed`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Logging may throw if not connected — swallow silently
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// Convert caught value to Error
|
|
108
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
109
|
+
SyncEngine._log(`Sync failed: ${err.message}`);
|
|
110
|
+
// Log error via MCP
|
|
111
|
+
try {
|
|
112
|
+
await this._context.server.sendLoggingMessage({
|
|
113
|
+
level: "error",
|
|
114
|
+
data: `Sync failed: ${err.message}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Logging may throw if not connected — swallow silently
|
|
119
|
+
}
|
|
120
|
+
// Emit error event
|
|
121
|
+
this._events.emit("sync:error", err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
static _log(msg) {
|
|
125
|
+
process.stderr.write(`[mcp-paprika:sync] ${msg}\n`);
|
|
126
|
+
}
|
|
127
|
+
async _loop() {
|
|
128
|
+
const signal = this._ac?.signal;
|
|
129
|
+
if (!signal)
|
|
130
|
+
return;
|
|
131
|
+
while (true) {
|
|
132
|
+
try {
|
|
133
|
+
await this.syncOnce();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// Defensive: syncOnce() should never throw (AC6.1), but catch here prevents unhandled rejections if the contract is violated
|
|
137
|
+
this._events.emit("sync:error", error instanceof Error ? error : new Error(String(error)));
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await scheduler.wait(this._intervalMs, { signal });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|