@bojanrajkovic/mcp-paprika 1.3.0 → 1.4.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 (62) hide show
  1. package/README.md +2 -2
  2. package/dist/auth/types.d.ts +24 -24
  3. package/dist/cache/aisle-store.d.ts +9 -0
  4. package/dist/cache/aisle-store.js +17 -0
  5. package/dist/cache/disk/root.d.ts +5 -1
  6. package/dist/cache/disk/root.js +40 -2
  7. package/dist/cache/grocery-ingredient-store.d.ts +18 -0
  8. package/dist/cache/grocery-ingredient-store.js +33 -0
  9. package/dist/cache/grocery-item-store.d.ts +16 -0
  10. package/dist/cache/grocery-item-store.js +29 -0
  11. package/dist/cache/grocery-list-store.d.ts +16 -0
  12. package/dist/cache/grocery-list-store.js +38 -0
  13. package/dist/cache/pantry-store.d.ts +2 -12
  14. package/dist/cache/pantry-store.js +2 -45
  15. package/dist/cache/recipe-store.d.ts +3 -0
  16. package/dist/cache/recipe-store.js +7 -0
  17. package/dist/entity/index.d.ts +1 -0
  18. package/dist/entity/index.js +1 -0
  19. package/dist/entity/tombstone-store.d.ts +10 -0
  20. package/dist/entity/tombstone-store.js +21 -0
  21. package/dist/paprika/client.d.ts +12 -13
  22. package/dist/paprika/client.js +83 -34
  23. package/dist/paprika/sync.d.ts +18 -1
  24. package/dist/paprika/sync.js +136 -57
  25. package/dist/paprika/types.d.ts +275 -13
  26. package/dist/paprika/types.js +109 -4
  27. package/dist/resources/grocery-lists.d.ts +3 -0
  28. package/dist/resources/grocery-lists.js +39 -0
  29. package/dist/resources/recipes.js +10 -1
  30. package/dist/server/app-context.d.ts +8 -0
  31. package/dist/server/build.d.ts +1 -1
  32. package/dist/server/build.js +67 -6
  33. package/dist/tools/aisle-helpers.d.ts +19 -0
  34. package/dist/tools/aisle-helpers.js +70 -0
  35. package/dist/{resources/pantry.d.ts → tools/aisles.d.ts} +1 -1
  36. package/dist/tools/aisles.js +24 -0
  37. package/dist/tools/categories.js +25 -4
  38. package/dist/tools/discover.js +4 -9
  39. package/dist/tools/filter.js +3 -9
  40. package/dist/tools/grocery-clear.d.ts +4 -0
  41. package/dist/tools/grocery-clear.js +71 -0
  42. package/dist/tools/grocery-helpers.d.ts +51 -0
  43. package/dist/tools/grocery-helpers.js +193 -0
  44. package/dist/tools/grocery-item.d.ts +5 -0
  45. package/dist/tools/grocery-item.js +223 -0
  46. package/dist/tools/grocery-list.d.ts +7 -0
  47. package/dist/tools/grocery-list.js +188 -0
  48. package/dist/tools/{pantry-add.d.ts → grocery-move.d.ts} +1 -1
  49. package/dist/tools/grocery-move.js +95 -0
  50. package/dist/tools/helpers.d.ts +1 -0
  51. package/dist/tools/helpers.js +23 -0
  52. package/dist/tools/pantry-batch-add.d.ts +3 -0
  53. package/dist/tools/pantry-batch-add.js +151 -0
  54. package/dist/tools/pantry-delete.js +2 -2
  55. package/dist/tools/pantry-helpers.d.ts +12 -5
  56. package/dist/tools/pantry-helpers.js +58 -7
  57. package/dist/tools/pantry-list.js +3 -1
  58. package/dist/tools/pantry-update.js +37 -13
  59. package/dist/tools/search.js +3 -18
  60. package/package.json +9 -8
  61. package/dist/resources/pantry.js +0 -32
  62. package/dist/tools/pantry-add.js +0 -76
package/README.md CHANGED
@@ -4,10 +4,10 @@ An [MCP](https://modelcontextprotocol.io/) server for [Paprika](https://www.papr
4
4
 
5
5
  ## Features
6
6
 
7
- - **14 tools** for recipe and pantry management — search, filter, CRUD, categories, pagination, pantry inventory
7
+ - **26 tools** for recipe, pantry, and grocery management — search, filter, CRUD, categories, pagination, pantry inventory, aisles, grocery list management, grocery item management
8
8
  - **Semantic search** via `discover_recipes` — find recipes by natural language description using any OpenAI-compatible embedding provider
9
9
  - **Background sync** — keeps your local cache in sync with Paprika's cloud
10
- - **MCP resources** — expose recipes as `paprika://recipe/{uid}` and pantry items as `paprika://pantry/{uid}` resources
10
+ - **MCP resources** — expose recipes as `paprika://recipe/{uid}` and grocery lists as `paprika://grocery-list/{uid}` resources
11
11
  - **Two transports** — stdio (default, for CLI clients) and Streamable HTTP (for mobile/web clients)
12
12
  - **Container image** — `Dockerfile` ships a distroless runtime ready for self-hosting
13
13
 
@@ -240,13 +240,13 @@ export declare const IdentitySchema: z.ZodObject<{
240
240
  sub: z.ZodString;
241
241
  source: z.ZodEnum<["email", "sub"]>;
242
242
  }, "strip", z.ZodTypeAny, {
243
- email: string | null;
243
+ source: "sub" | "email";
244
244
  sub: string;
245
- source: "email" | "sub";
246
- }, {
247
245
  email: string | null;
246
+ }, {
247
+ source: "sub" | "email";
248
248
  sub: string;
249
- source: "email" | "sub";
249
+ email: string | null;
250
250
  }>;
251
251
  /**
252
252
  * RFC 8707 resource indicator — must be a fully-qualified URL or an explicit empty
@@ -264,13 +264,13 @@ export declare const OAuthTokenSchema: z.ZodObject<{
264
264
  sub: z.ZodString;
265
265
  source: z.ZodEnum<["email", "sub"]>;
266
266
  }, "strip", z.ZodTypeAny, {
267
- email: string | null;
267
+ source: "sub" | "email";
268
268
  sub: string;
269
- source: "email" | "sub";
270
- }, {
271
269
  email: string | null;
270
+ }, {
271
+ source: "sub" | "email";
272
272
  sub: string;
273
- source: "email" | "sub";
273
+ email: string | null;
274
274
  }>;
275
275
  resource: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
276
276
  expiresAt: z.ZodNumber;
@@ -282,9 +282,9 @@ export declare const OAuthTokenSchema: z.ZodObject<{
282
282
  scope: string;
283
283
  createdAt: number;
284
284
  identity: {
285
- email: string | null;
285
+ source: "sub" | "email";
286
286
  sub: string;
287
- source: "email" | "sub";
287
+ email: string | null;
288
288
  };
289
289
  tokenHash: string;
290
290
  kind: "access" | "refresh";
@@ -296,9 +296,9 @@ export declare const OAuthTokenSchema: z.ZodObject<{
296
296
  scope: string;
297
297
  createdAt: number;
298
298
  identity: {
299
- email: string | null;
299
+ source: "sub" | "email";
300
300
  sub: string;
301
- source: "email" | "sub";
301
+ email: string | null;
302
302
  };
303
303
  tokenHash: string;
304
304
  kind: "access" | "refresh";
@@ -353,13 +353,13 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
353
353
  sub: z.ZodString;
354
354
  source: z.ZodEnum<["email", "sub"]>;
355
355
  }, "strip", z.ZodTypeAny, {
356
- email: string | null;
356
+ source: "sub" | "email";
357
357
  sub: string;
358
- source: "email" | "sub";
359
- }, {
360
358
  email: string | null;
359
+ }, {
360
+ source: "sub" | "email";
361
361
  sub: string;
362
- source: "email" | "sub";
362
+ email: string | null;
363
363
  }>;
364
364
  }, "strip", z.ZodTypeAny, {
365
365
  clientId: string;
@@ -370,9 +370,9 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
370
370
  scope: string;
371
371
  createdAt: number;
372
372
  identity: {
373
- email: string | null;
373
+ source: "sub" | "email";
374
374
  sub: string;
375
- source: "email" | "sub";
375
+ email: string | null;
376
376
  };
377
377
  }, {
378
378
  clientId: string;
@@ -383,9 +383,9 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
383
383
  scope: string;
384
384
  createdAt: number;
385
385
  identity: {
386
- email: string | null;
386
+ source: "sub" | "email";
387
387
  sub: string;
388
- source: "email" | "sub";
388
+ email: string | null;
389
389
  };
390
390
  }>;
391
391
  export type AuthCodeState = z.infer<typeof AuthCodeStateSchema>;
@@ -423,13 +423,13 @@ export declare const AuthInfoExtraSchema: z.ZodObject<{
423
423
  sub: z.ZodString;
424
424
  source: z.ZodEnum<["email", "sub"]>;
425
425
  }, "strip", z.ZodTypeAny, {
426
- email: string | null;
426
+ source: "sub" | "email";
427
427
  sub: string;
428
- source: "email" | "sub";
429
- }, {
430
428
  email: string | null;
429
+ }, {
430
+ source: "sub" | "email";
431
431
  sub: string;
432
- source: "email" | "sub";
432
+ email: string | null;
433
433
  }>;
434
434
  export type AuthInfoExtra = z.infer<typeof AuthInfoExtraSchema>;
435
435
  export interface AuthContext {
@@ -0,0 +1,9 @@
1
+ import { EntityStore } from "../entity/index.js";
2
+ import type { Aisle, AisleUid } from "../paprika/types.js";
3
+ export declare class AisleStore extends EntityStore<Aisle, AisleUid> {
4
+ constructor(opts?: {
5
+ readonly pendingWriteTtlMs?: number;
6
+ });
7
+ load(items: ReadonlyArray<Aisle>): void;
8
+ resolveByName(name: string): Aisle | undefined;
9
+ }
@@ -0,0 +1,17 @@
1
+ import { EntityStore } from "../entity/index.js";
2
+ export class AisleStore extends EntityStore {
3
+ constructor(opts) {
4
+ super(opts ?? {});
5
+ }
6
+ load(items) {
7
+ this.baseLoad(items);
8
+ }
9
+ resolveByName(name) {
10
+ const needle = name.toLowerCase();
11
+ for (const aisle of this._items.values()) {
12
+ if (aisle.name.toLowerCase() === needle)
13
+ return aisle;
14
+ }
15
+ return undefined;
16
+ }
17
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Logger } from "pino";
2
2
  import type { OAuthToken } from "../../auth/types.js";
3
- import type { Category, PantryItem } from "../../paprika/types.js";
3
+ import type { Aisle, Category, GroceryIngredient, GroceryItem, GroceryList, PantryItem } from "../../paprika/types.js";
4
4
  import { DiskCache } from "./base.js";
5
5
  import { OAuthClientDiskCache } from "./oauth-clients.js";
6
6
  import { RecipeDiskCache } from "./recipes.js";
@@ -18,8 +18,12 @@ export declare class DiskCacheRoot {
18
18
  readonly recipes: RecipeDiskCache;
19
19
  readonly categories: DiskCache<Category>;
20
20
  readonly pantry: DiskCache<PantryItem>;
21
+ readonly aisles: DiskCache<Aisle>;
21
22
  readonly oauthClients: OAuthClientDiskCache;
22
23
  readonly oauthTokens: DiskCache<OAuthToken>;
24
+ readonly groceryLists: DiskCache<GroceryList>;
25
+ readonly groceryItems: DiskCache<GroceryItem>;
26
+ readonly groceryIngredients: DiskCache<GroceryIngredient>;
23
27
  private readonly _cacheDir;
24
28
  private readonly _subcaches;
25
29
  private readonly log;
@@ -2,7 +2,7 @@ import { mkdir, readFile, rename, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { OAuthTokenSchema } from "../../auth/types.js";
5
- import { CategoryStoredSchema, PantryItemStoredSchema } from "../../paprika/types.js";
5
+ import { AisleStoredSchema, CategoryStoredSchema, GroceryIngredientStoredSchema, GroceryItemStoredSchema, GroceryListStoredSchema, PantryItemStoredSchema, } from "../../paprika/types.js";
6
6
  import { isNodeError } from "../../utils/errors.js";
7
7
  import { SILENT_LOG } from "../../utils/log.js";
8
8
  import { DiskCache, writeFileAtomic } from "./base.js";
@@ -27,8 +27,12 @@ export class DiskCacheRoot {
27
27
  recipes;
28
28
  categories;
29
29
  pantry;
30
+ aisles;
30
31
  oauthClients;
31
32
  oauthTokens;
33
+ groceryLists;
34
+ groceryItems;
35
+ groceryIngredients;
32
36
  _cacheDir;
33
37
  _subcaches;
34
38
  log;
@@ -49,6 +53,12 @@ export class DiskCacheRoot {
49
53
  getKey: (i) => i.uid,
50
54
  ...logOpts,
51
55
  });
56
+ this.aisles = new DiskCache({
57
+ subdir: join(cacheDir, "aisles"),
58
+ parse: (raw) => AisleStoredSchema.parse(raw),
59
+ getKey: (a) => a.uid,
60
+ ...logOpts,
61
+ });
52
62
  this.oauthClients = new OAuthClientDiskCache({ subdir: join(cacheDir, "oauthClients"), ...logOpts });
53
63
  this.oauthTokens = new DiskCache({
54
64
  subdir: join(cacheDir, "oauthTokens"),
@@ -56,7 +66,35 @@ export class DiskCacheRoot {
56
66
  getKey: (t) => t.tokenHash,
57
67
  ...logOpts,
58
68
  });
59
- this._subcaches = [this.recipes, this.categories, this.pantry, this.oauthClients, this.oauthTokens];
69
+ this.groceryLists = new DiskCache({
70
+ subdir: join(cacheDir, "grocerylists"),
71
+ parse: (raw) => GroceryListStoredSchema.parse(raw),
72
+ getKey: (l) => l.uid,
73
+ ...logOpts,
74
+ });
75
+ this.groceryItems = new DiskCache({
76
+ subdir: join(cacheDir, "groceryitems"),
77
+ parse: (raw) => GroceryItemStoredSchema.parse(raw),
78
+ getKey: (i) => i.uid,
79
+ ...logOpts,
80
+ });
81
+ this.groceryIngredients = new DiskCache({
82
+ subdir: join(cacheDir, "groceryingredients"),
83
+ parse: (raw) => GroceryIngredientStoredSchema.parse(raw),
84
+ getKey: (i) => i.uid,
85
+ ...logOpts,
86
+ });
87
+ this._subcaches = [
88
+ this.recipes,
89
+ this.categories,
90
+ this.pantry,
91
+ this.aisles,
92
+ this.oauthClients,
93
+ this.oauthTokens,
94
+ this.groceryLists,
95
+ this.groceryItems,
96
+ this.groceryIngredients,
97
+ ];
60
98
  }
61
99
  async init() {
62
100
  await this._maybeMigrateLegacyIndex();
@@ -0,0 +1,18 @@
1
+ import type { GroceryIngredient } from "../paprika/types.js";
2
+ /**
3
+ * In-memory store for grocery ingredients, keyed by lowercase name for
4
+ * case-insensitive lookup. Unlike GroceryListStore and GroceryItemStore,
5
+ * this is a plain class — not an EntityStore subclass — because ingredients
6
+ * have no pending-writes, no tombstones, and no sweepPending. Internal
7
+ * storage is a Map<string, GroceryIngredient> keyed by lowercase name.
8
+ */
9
+ export declare class GroceryIngredientStore {
10
+ private readonly _items;
11
+ private _hasSynced;
12
+ get hasSynced(): boolean;
13
+ get size(): number;
14
+ load(items: ReadonlyArray<GroceryIngredient>): void;
15
+ set(ingredient: GroceryIngredient): void;
16
+ lookupByName(name: string): GroceryIngredient | undefined;
17
+ getAll(): Array<GroceryIngredient>;
18
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * In-memory store for grocery ingredients, keyed by lowercase name for
3
+ * case-insensitive lookup. Unlike GroceryListStore and GroceryItemStore,
4
+ * this is a plain class — not an EntityStore subclass — because ingredients
5
+ * have no pending-writes, no tombstones, and no sweepPending. Internal
6
+ * storage is a Map<string, GroceryIngredient> keyed by lowercase name.
7
+ */
8
+ export class GroceryIngredientStore {
9
+ _items = new Map();
10
+ _hasSynced = false;
11
+ get hasSynced() {
12
+ return this._hasSynced;
13
+ }
14
+ get size() {
15
+ return this._items.size;
16
+ }
17
+ load(items) {
18
+ this._items.clear();
19
+ for (const item of items) {
20
+ this._items.set(item.name.toLowerCase(), item);
21
+ }
22
+ this._hasSynced = true;
23
+ }
24
+ set(ingredient) {
25
+ this._items.set(ingredient.name.toLowerCase(), ingredient);
26
+ }
27
+ lookupByName(name) {
28
+ return this._items.get(name.toLowerCase());
29
+ }
30
+ getAll() {
31
+ return [...this._items.values()];
32
+ }
33
+ }
@@ -0,0 +1,16 @@
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
+ import type { GroceryItem, GroceryItemUid } from "../paprika/types.js";
3
+ export declare class GroceryItemStore extends TombstoneEntityStore<GroceryItem, GroceryItemUid> {
4
+ constructor(opts?: {
5
+ readonly pendingWriteTtlMs?: number;
6
+ });
7
+ /**
8
+ * Returns all non-tombstoned items whose listUid matches the given value.
9
+ */
10
+ getByListUid(listUid: string): Array<GroceryItem>;
11
+ /**
12
+ * Returns all non-tombstoned items in the given list that have been
13
+ * marked as purchased.
14
+ */
15
+ getPurchasedByList(listUid: string): Array<GroceryItem>;
16
+ }
@@ -0,0 +1,29 @@
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
+ export class GroceryItemStore extends TombstoneEntityStore {
3
+ constructor(opts) {
4
+ super(opts ?? {});
5
+ }
6
+ /**
7
+ * Returns all non-tombstoned items whose listUid matches the given value.
8
+ */
9
+ getByListUid(listUid) {
10
+ const result = [];
11
+ for (const item of this._items.values()) {
12
+ if (item.listUid === listUid)
13
+ result.push(item);
14
+ }
15
+ return result;
16
+ }
17
+ /**
18
+ * Returns all non-tombstoned items in the given list that have been
19
+ * marked as purchased.
20
+ */
21
+ getPurchasedByList(listUid) {
22
+ const result = [];
23
+ for (const item of this._items.values()) {
24
+ if (item.listUid === listUid && item.purchased)
25
+ result.push(item);
26
+ }
27
+ return result;
28
+ }
29
+ }
@@ -0,0 +1,16 @@
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
+ import type { GroceryList, GroceryListUid } from "../paprika/types.js";
3
+ export declare class GroceryListStore extends TombstoneEntityStore<GroceryList, GroceryListUid> {
4
+ private _lastSyncedAt;
5
+ constructor(opts?: {
6
+ readonly pendingWriteTtlMs?: number;
7
+ });
8
+ get lastSyncedAt(): Date | null;
9
+ setLastSyncedAt(at?: Date): void;
10
+ /**
11
+ * Tiered case-insensitive name lookup: exact > starts-with > contains.
12
+ * Returns items from at most one tier. Excludes deleted items (they are
13
+ * removed from _items by delete() before this is called).
14
+ */
15
+ findByName(query: string): Array<GroceryList>;
16
+ }
@@ -0,0 +1,38 @@
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
+ export class GroceryListStore extends TombstoneEntityStore {
3
+ _lastSyncedAt = null;
4
+ constructor(opts) {
5
+ super(opts ?? {});
6
+ }
7
+ get lastSyncedAt() {
8
+ return this._lastSyncedAt;
9
+ }
10
+ setLastSyncedAt(at = new Date()) {
11
+ this._lastSyncedAt = at;
12
+ }
13
+ /**
14
+ * Tiered case-insensitive name lookup: exact > starts-with > contains.
15
+ * Returns items from at most one tier. Excludes deleted items (they are
16
+ * removed from _items by delete() before this is called).
17
+ */
18
+ findByName(query) {
19
+ const needle = query.toLowerCase();
20
+ const exact = [];
21
+ const startsWith = [];
22
+ const contains = [];
23
+ for (const list of this._items.values()) {
24
+ const name = list.name.toLowerCase();
25
+ if (name === needle)
26
+ exact.push(list);
27
+ else if (name.startsWith(needle))
28
+ startsWith.push(list);
29
+ else if (name.includes(needle))
30
+ contains.push(list);
31
+ }
32
+ if (exact.length > 0)
33
+ return exact;
34
+ if (startsWith.length > 0)
35
+ return startsWith;
36
+ return contains;
37
+ }
38
+ }
@@ -1,18 +1,8 @@
1
- import { EntityStore } from "../entity/index.js";
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
2
  import type { PantryItem, PantryItemUid } from "../paprika/types.js";
3
- export declare class PantryStore extends EntityStore<PantryItem, PantryItemUid> {
4
- private readonly _tombstones;
3
+ export declare class PantryStore extends TombstoneEntityStore<PantryItem, PantryItemUid> {
5
4
  constructor(opts?: {
6
5
  readonly pendingWriteTtlMs?: number;
7
6
  });
8
- load(items: ReadonlyArray<PantryItem>): void;
9
- set(item: PantryItem): void;
10
- delete(uid: PantryItemUid): void;
11
- /**
12
- * Returns true if `uid` was soft-deleted via this store in the current
13
- * session (since the last `load()`). Used by `delete_pantry_item` to give
14
- * idempotent retried-delete callers a clear "already deleted" signal.
15
- */
16
- isTombstone(uid: PantryItemUid): boolean;
17
7
  findByIngredient(query: string): Array<PantryItem>;
18
8
  }
@@ -1,51 +1,8 @@
1
- import { EntityStore } from "../entity/index.js";
2
- export class PantryStore extends EntityStore {
3
- // Tombstones track UIDs that were soft-deleted via this client, so
4
- // `delete_pantry_item` can return a distinct "already deleted" message for
5
- // retried calls (server upserts by UID and the live-items map alone can't
6
- // distinguish "I deleted this" from "this never existed"). Tombstones
7
- // persist across `load()` so delayed retries that span a sync cycle still
8
- // get the idempotent signal — `load()` only un-tombstones UIDs that are
9
- // now back in the live items list (i.e. resurrected by the server, e.g.,
10
- // un-deleted via another client). The tombstone set therefore stays
11
- // disjoint from `_items` after every load.
12
- _tombstones = new Set();
1
+ import { TombstoneEntityStore } from "../entity/index.js";
2
+ export class PantryStore extends TombstoneEntityStore {
13
3
  constructor(opts) {
14
4
  super(opts ?? {});
15
5
  }
16
- load(items) {
17
- this.baseLoad(items);
18
- // Un-tombstone any UID that came back in the snapshot (resurrection: item
19
- // returned from the server supersedes the local soft-delete).
20
- for (const item of items) {
21
- this._tombstones.delete(item.uid);
22
- }
23
- }
24
- set(item) {
25
- super.set(item);
26
- this._tombstones.delete(item.uid);
27
- }
28
- delete(uid) {
29
- // Always tombstone, regardless of whether `uid` is currently in `_items`.
30
- // The only caller is `commitPantryItem`'s delete branch (post-successful
31
- // savePantryItem), but several awaits separate the save from the local
32
- // commit; SyncEngine.syncOnce() can interleave a `load(...)` that wipes
33
- // the UID from `_items` before commit lands. Conditioning the tombstone
34
- // on `_items.has(uid)` would silently drop the idempotent retry signal
35
- // in exactly that race. Spurious tombstones from other callers are
36
- // acceptable: an extra "already deleted" message is harmless; a missing
37
- // one isn't.
38
- this._tombstones.add(uid);
39
- super.delete(uid);
40
- }
41
- /**
42
- * Returns true if `uid` was soft-deleted via this store in the current
43
- * session (since the last `load()`). Used by `delete_pantry_item` to give
44
- * idempotent retried-delete callers a clear "already deleted" signal.
45
- */
46
- isTombstone(uid) {
47
- return this._tombstones.has(uid);
48
- }
49
6
  findByIngredient(query) {
50
7
  const needle = query.toLowerCase();
51
8
  const exact = [];
@@ -16,9 +16,12 @@ export type TimeConstraints = {
16
16
  };
17
17
  export declare class RecipeStore extends EntityStore<Recipe, RecipeUid> {
18
18
  private readonly categories;
19
+ private _lastSyncedAt;
19
20
  constructor(opts?: {
20
21
  readonly pendingWriteTtlMs?: number;
21
22
  });
23
+ get lastSyncedAt(): Date | null;
24
+ setLastSyncedAt(at?: Date): void;
22
25
  load(recipes: ReadonlyArray<Recipe>, categories: ReadonlyArray<Category>): void;
23
26
  getAll(): Array<Recipe>;
24
27
  get size(): number;
@@ -2,9 +2,16 @@ import { EntityStore } from "../entity/index.js";
2
2
  import { parseDuration } from "../utils/duration.js";
3
3
  export class RecipeStore extends EntityStore {
4
4
  categories = new Map();
5
+ _lastSyncedAt = null;
5
6
  constructor(opts) {
6
7
  super(opts ?? {});
7
8
  }
9
+ get lastSyncedAt() {
10
+ return this._lastSyncedAt;
11
+ }
12
+ setLastSyncedAt(at = new Date()) {
13
+ this._lastSyncedAt = at;
14
+ }
8
15
  load(recipes, categories) {
9
16
  this.baseLoad(recipes);
10
17
  this.setCategories(categories);
@@ -1 +1,2 @@
1
1
  export { EntityStore, type PendingWrite } from "./store.js";
2
+ export { TombstoneEntityStore } from "./tombstone-store.js";
@@ -1 +1,2 @@
1
1
  export { EntityStore } from "./store.js";
2
+ export { TombstoneEntityStore } from "./tombstone-store.js";
@@ -0,0 +1,10 @@
1
+ import { EntityStore } from "./store.js";
2
+ export declare abstract class TombstoneEntityStore<T extends {
3
+ uid: UID;
4
+ }, UID extends string> extends EntityStore<T, UID> {
5
+ private readonly _tombstones;
6
+ load(items: ReadonlyArray<T>): void;
7
+ set(item: T): void;
8
+ delete(uid: UID): void;
9
+ isTombstone(uid: UID): boolean;
10
+ }
@@ -0,0 +1,21 @@
1
+ import { EntityStore } from "./store.js";
2
+ export class TombstoneEntityStore extends EntityStore {
3
+ _tombstones = new Set();
4
+ load(items) {
5
+ this.baseLoad(items);
6
+ for (const item of items) {
7
+ this._tombstones.delete(this.getUid(item));
8
+ }
9
+ }
10
+ set(item) {
11
+ super.set(item);
12
+ this._tombstones.delete(this.getUid(item));
13
+ }
14
+ delete(uid) {
15
+ this._tombstones.add(uid);
16
+ super.delete(uid);
17
+ }
18
+ isTombstone(uid) {
19
+ return this._tombstones.has(uid);
20
+ }
21
+ }
@@ -1,14 +1,5 @@
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
1
  import type { Logger } from "pino";
11
- import type { Category, PantryItem, Recipe, RecipeEntry, RecipeUid } from "./types.js";
2
+ import type { Aisle, Category, GroceryIngredient, GroceryItem, GroceryList, PantryItem, Recipe, RecipeEntry, RecipeUid } from "./types.js";
12
3
  export declare class PaprikaClient {
13
4
  private readonly email;
14
5
  private readonly password;
@@ -24,12 +15,20 @@ export declare class PaprikaClient {
24
15
  getRecipe(uid: string): Promise<Recipe>;
25
16
  getRecipes(uids: ReadonlyArray<string>): Promise<Array<Recipe>>;
26
17
  listCategories(): Promise<Array<Category>>;
18
+ listAisles(): Promise<Array<Aisle>>;
19
+ listGroceryLists(): Promise<Array<GroceryList>>;
20
+ listGroceryItems(): Promise<Array<GroceryItem>>;
21
+ listGroceryIngredients(): Promise<Array<GroceryIngredient>>;
27
22
  listPantry(): Promise<Array<PantryItem>>;
28
23
  saveRecipe(recipe: Readonly<Recipe>): Promise<Recipe>;
29
- savePantryItem(item: Readonly<PantryItem>): Promise<PantryItem>;
24
+ saveAisle(aisle: Readonly<Aisle>): Promise<Aisle>;
25
+ savePantryItems(items: ReadonlyArray<Readonly<PantryItem>>): Promise<ReadonlyArray<PantryItem>>;
26
+ saveGroceryList(list: Readonly<GroceryList>): Promise<GroceryList>;
27
+ saveGroceryItems(items: ReadonlyArray<Readonly<GroceryItem>>): Promise<ReadonlyArray<GroceryItem>>;
28
+ saveGroceryIngredient(ingredient: Readonly<GroceryIngredient>): Promise<GroceryIngredient>;
30
29
  notifySync(): Promise<void>;
31
30
  deleteRecipe(uid: RecipeUid): Promise<void>;
32
- private buildRecipeFormData;
33
- private buildPantryFormData;
31
+ private buildEntityFormData;
32
+ private postEntities;
34
33
  private request;
35
34
  }