@fullwell/fullwell 1.1.11 → 1.1.13
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +9 -1
- package/install-metadata.json +2 -2
- package/package.json +1 -1
- package/references/mcp-tool-contract.md +12 -1
- package/references/meal-planning-and-food-constraints.md +53 -0
- package/references/privacy-and-sharing.md +8 -0
- package/references/semantic-food-rules.md +4 -0
- package/references/weekly-meal-planning-automation.md +69 -0
- package/runtime/local-household-mcp.mjs +326 -21
- package/runtime/local-household.mjs +1128 -49
- package/runtime/local-profile.mjs +146 -0
- package/runtime/local-recipe-board.mjs +358 -0
- package/runtime/local-runner-control.mjs +61 -0
- package/skills/manage-household-food-journal/SKILL.md +46 -21
- package/skills/plan-household-meals/SKILL.md +57 -0
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
stat,
|
|
13
13
|
unlink,
|
|
14
14
|
} from "node:fs/promises";
|
|
15
|
-
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { pathToFileURL } from "node:url";
|
|
@@ -22,9 +22,30 @@ const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024;
|
|
|
22
22
|
const MAX_JSON_DEPTH = 64;
|
|
23
23
|
const MAX_JSON_NODES = 250_000;
|
|
24
24
|
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
25
|
+
const LOCK_WAIT_MS = 1_000;
|
|
26
|
+
const LOCK_RETRY_MS = 20;
|
|
27
|
+
const MAX_MEAL_PROPOSALS = 2_000;
|
|
28
|
+
const MAX_MEAL_REVIEW_EVENTS = 2_000;
|
|
29
|
+
const MAX_MEAL_WITHDRAWAL_EVENTS = MAX_MEAL_PROPOSALS;
|
|
30
|
+
const MAX_MEAL_EVENTS = MAX_MEAL_REVIEW_EVENTS + MAX_MEAL_WITHDRAWAL_EVENTS;
|
|
31
|
+
const MAX_MEAL_IDEMPOTENCY_RECEIPTS = 6_000;
|
|
32
|
+
const MAX_MEAL_PROPOSALS_PER_SLOT = 48;
|
|
33
|
+
const MAX_MEAL_PROPOSALS_PER_WEEK = 500;
|
|
34
|
+
const MAX_MEAL_REVIEW_EVENTS_PER_WEEK = 500;
|
|
35
|
+
const MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK = MAX_MEAL_PROPOSALS_PER_WEEK;
|
|
36
|
+
const MAX_MEAL_WEEKS = 520;
|
|
37
|
+
const IANA_TIME_ZONES = new Set(["UTC", ...Intl.supportedValuesOf("timeZone")]);
|
|
25
38
|
const LOCAL_ID_PATTERN = /^lcl_[0-9a-f]{32}$/;
|
|
26
39
|
const FULLWELL_ID_PATTERN = /^(?:usr|hsh)_[0-9a-z]{16,64}$/;
|
|
27
40
|
const HEAD_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
41
|
+
const ITEM_ID_PATTERN = /^itm_[0-9a-z]{16,64}$/;
|
|
42
|
+
const EVIDENCE_ID_PATTERN = /^evd_[0-9a-z]{16,64}$/;
|
|
43
|
+
const MEAL_PROPOSAL_ID_PATTERN = /^mlp_[0-9a-z]{16,64}$/;
|
|
44
|
+
const MEAL_EVENT_ID_PATTERN = /^mle_[0-9a-z]{16,64}$/;
|
|
45
|
+
const MEAL_PROFILE_RECEIPT_ID_PATTERN = /^mlr_[0-9a-f]{32}$/;
|
|
46
|
+
const LOCAL_RECIPE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
47
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
|
|
48
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
49
|
const FORBIDDEN_JOURNAL_KEYS = new Set([
|
|
29
50
|
"access_token",
|
|
30
51
|
"authorization",
|
|
@@ -69,6 +90,20 @@ function assertExactKeys(value, allowed, label) {
|
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
function canonicalJson(value) {
|
|
94
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
95
|
+
if (!isPlainObject(value)) return value;
|
|
96
|
+
return Object.fromEntries(
|
|
97
|
+
Object.entries(value)
|
|
98
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
99
|
+
.map(([key, child]) => [key, canonicalJson(child)]),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stableJson(value) {
|
|
104
|
+
return JSON.stringify(canonicalJson(value));
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
function assertRevision(value, label = "expected_revision") {
|
|
73
108
|
if (!Number.isSafeInteger(value) || value < 1) fail("VALIDATION_FAILED", `${label} must be a positive integer`);
|
|
74
109
|
return value;
|
|
@@ -91,14 +126,436 @@ function assertDate(value, label) {
|
|
|
91
126
|
return value;
|
|
92
127
|
}
|
|
93
128
|
|
|
129
|
+
/** Detects ASCII control bytes, with optional space rejection for URL inputs. */
|
|
130
|
+
export function hasForbiddenAscii(value, forbidSpace = false) {
|
|
131
|
+
for (const character of value) {
|
|
132
|
+
const code = character.codePointAt(0);
|
|
133
|
+
if (code <= 31 || code === 127 || (forbidSpace && code === 32)) return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertBoundedText(value, label, maximum, { nullable = false } = {}) {
|
|
139
|
+
if (nullable && value === null) return null;
|
|
140
|
+
if (typeof value !== "string"
|
|
141
|
+
|| value.length < 1
|
|
142
|
+
|| value.length > maximum
|
|
143
|
+
|| !value.isWellFormed()
|
|
144
|
+
|| value.trim() !== value
|
|
145
|
+
|| hasForbiddenAscii(value)) {
|
|
146
|
+
fail("VALIDATION_FAILED", `${label} must be trimmed text of at most ${maximum} characters`);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function assertHouseholdName(value) {
|
|
152
|
+
if (typeof value !== "string" || !value.isWellFormed() || hasForbiddenAscii(value)) {
|
|
153
|
+
fail("VALIDATION_FAILED", "household_name must be trimmed text of at most 120 characters");
|
|
154
|
+
}
|
|
155
|
+
return assertBoundedText(value.trim(), "household_name", 120);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function assertIsoDateTime(value, label) {
|
|
159
|
+
if (typeof value !== "string"
|
|
160
|
+
|| !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
|
161
|
+
|| !Number.isFinite(Date.parse(value))) {
|
|
162
|
+
fail("VALIDATION_FAILED", `${label} must be an ISO date-time`);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertIsoDate(value, label) {
|
|
168
|
+
if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) fail("VALIDATION_FAILED", `${label} must be an ISO date`);
|
|
169
|
+
const parsed = new Date(`${value}T00:00:00.000Z`);
|
|
170
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) fail("VALIDATION_FAILED", `${label} is invalid`);
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertMonday(value, label = "week_start") {
|
|
175
|
+
const date = assertIsoDate(value, label);
|
|
176
|
+
if (new Date(`${date}T00:00:00.000Z`).getUTCDay() !== 1) fail("VALIDATION_FAILED", `${label} must be a Monday`);
|
|
177
|
+
return date;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertMealDate(weekStart, value) {
|
|
181
|
+
const mealDate = assertIsoDate(value, "meal_date");
|
|
182
|
+
const start = Date.parse(`${weekStart}T00:00:00.000Z`);
|
|
183
|
+
const offset = (Date.parse(`${mealDate}T00:00:00.000Z`) - start) / 86_400_000;
|
|
184
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > 6) fail("VALIDATION_FAILED", "meal_date must fall within week_start");
|
|
185
|
+
return mealDate;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function assertIanaTimeZone(value) {
|
|
189
|
+
if (typeof value !== "string" || value.length > 100 || !IANA_TIME_ZONES.has(value)) {
|
|
190
|
+
fail("VALIDATION_FAILED", "time_zone is invalid");
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertIdempotencyKey(value) {
|
|
196
|
+
if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) fail("VALIDATION_FAILED", "idempotency_key is invalid");
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertLocalActor(label) {
|
|
201
|
+
return { kind: "local", label: assertBoundedText(label, "actor_label", 80) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function assertHttpsUrl(value, label, { nullable = false } = {}) {
|
|
205
|
+
if (nullable && value === null) return null;
|
|
206
|
+
if (typeof value !== "string" || value.length > 2_048 || hasForbiddenAscii(value, true)) {
|
|
207
|
+
fail("VALIDATION_FAILED", `${label} must be a bounded HTTPS URL`);
|
|
208
|
+
}
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = new URL(value);
|
|
212
|
+
} catch {
|
|
213
|
+
fail("VALIDATION_FAILED", `${label} must be a valid HTTPS URL`);
|
|
214
|
+
}
|
|
215
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
|
|
216
|
+
fail("VALIDATION_FAILED", `${label} must be a credential-free HTTPS URL`);
|
|
217
|
+
}
|
|
218
|
+
return parsed.toString();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertUniqueTextList(value, label, maximumItems, maximumText) {
|
|
222
|
+
if (!Array.isArray(value) || value.length > maximumItems) fail("VALIDATION_FAILED", `${label} has too many values`);
|
|
223
|
+
const parsed = value.map((entry, index) => assertBoundedText(entry, `${label}[${index}]`, maximumText));
|
|
224
|
+
const normalized = parsed.map((entry) => entry.toLocaleLowerCase());
|
|
225
|
+
if (new Set(normalized).size !== normalized.length) fail("VALIDATION_FAILED", `${label} values must be unique`);
|
|
226
|
+
return parsed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function assertMealConstraints(value) {
|
|
230
|
+
if (!isPlainObject(value) || typeof value.status !== "string") fail("VALIDATION_FAILED", "constraints must be an object");
|
|
231
|
+
if (value.status === "unresolved") {
|
|
232
|
+
assertExactKeys(value, new Set(["status"]), "constraints");
|
|
233
|
+
return { status: "unresolved" };
|
|
234
|
+
}
|
|
235
|
+
if (value.status === "confirmed_none") {
|
|
236
|
+
assertExactKeys(value, new Set(["status", "time_zone", "reviewed_at"]), "constraints");
|
|
237
|
+
return {
|
|
238
|
+
status: "confirmed_none",
|
|
239
|
+
time_zone: assertIanaTimeZone(value.time_zone),
|
|
240
|
+
reviewed_at: assertIsoDateTime(value.reviewed_at, "constraints.reviewed_at"),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (value.status === "recorded") {
|
|
244
|
+
assertExactKeys(value, new Set([
|
|
245
|
+
"status",
|
|
246
|
+
"time_zone",
|
|
247
|
+
"allergy_labels",
|
|
248
|
+
"sensitivity_labels",
|
|
249
|
+
"reviewed_at",
|
|
250
|
+
]), "constraints");
|
|
251
|
+
const allergyLabels = assertUniqueTextList(value.allergy_labels, "allergy_labels", 30, 120);
|
|
252
|
+
const sensitivityLabels = assertUniqueTextList(value.sensitivity_labels, "sensitivity_labels", 30, 120);
|
|
253
|
+
const combined = [...allergyLabels, ...sensitivityLabels].map((entry) => entry.toLocaleLowerCase());
|
|
254
|
+
if (combined.length === 0 || new Set(combined).size !== combined.length) {
|
|
255
|
+
fail("VALIDATION_FAILED", "recorded constraints require unique allergy or sensitivity labels");
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
status: "recorded",
|
|
259
|
+
time_zone: assertIanaTimeZone(value.time_zone),
|
|
260
|
+
allergy_labels: allergyLabels,
|
|
261
|
+
sensitivity_labels: sensitivityLabels,
|
|
262
|
+
reviewed_at: assertIsoDateTime(value.reviewed_at, "constraints.reviewed_at"),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
fail("VALIDATION_FAILED", "constraints status is unsupported");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function assertMealSlot(value) {
|
|
269
|
+
if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "slot must be an object");
|
|
270
|
+
if (["breakfast", "lunch", "dinner", "snack"].includes(value.kind)) {
|
|
271
|
+
assertExactKeys(value, new Set(["kind"]), "slot");
|
|
272
|
+
return { kind: value.kind };
|
|
273
|
+
}
|
|
274
|
+
if (value.kind === "custom") {
|
|
275
|
+
assertExactKeys(value, new Set(["kind", "label"]), "slot");
|
|
276
|
+
return { kind: "custom", label: assertBoundedText(value.label, "slot.label", 80) };
|
|
277
|
+
}
|
|
278
|
+
fail("VALIDATION_FAILED", "slot kind is unsupported");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function mealSlotKey(slot) {
|
|
282
|
+
return slot.kind === "custom" ? `custom:${slot.label}` : slot.kind;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function assertOpaqueId(value, pattern, label) {
|
|
286
|
+
if (typeof value !== "string" || !pattern.test(value)) fail("VALIDATION_FAILED", `${label} is invalid`);
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function assertLocalMealSource(value) {
|
|
291
|
+
if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "source must be an object");
|
|
292
|
+
if (value.kind === "freeform") {
|
|
293
|
+
assertExactKeys(value, new Set(["kind", "title"]), "source");
|
|
294
|
+
return { kind: "freeform", title: assertBoundedText(value.title, "source.title", 300) };
|
|
295
|
+
}
|
|
296
|
+
if (value.kind === "journal_recipe") {
|
|
297
|
+
assertExactKeys(value, new Set(["kind", "item_id", "item_revision", "liked_evidence_ids"]), "source");
|
|
298
|
+
if (!Array.isArray(value.liked_evidence_ids) || value.liked_evidence_ids.length < 1 || value.liked_evidence_ids.length > 100) {
|
|
299
|
+
fail("VALIDATION_FAILED", "source.liked_evidence_ids must contain between 1 and 100 IDs");
|
|
300
|
+
}
|
|
301
|
+
const evidenceIds = value.liked_evidence_ids.map((entry) => assertOpaqueId(entry, EVIDENCE_ID_PATTERN, "liked evidence ID"));
|
|
302
|
+
if (new Set(evidenceIds).size !== evidenceIds.length) fail("VALIDATION_FAILED", "source.liked_evidence_ids must be unique");
|
|
303
|
+
return {
|
|
304
|
+
kind: "journal_recipe",
|
|
305
|
+
item_id: assertOpaqueId(value.item_id, ITEM_ID_PATTERN, "source.item_id"),
|
|
306
|
+
item_revision: assertOpaqueId(value.item_revision, LOCAL_RECIPE_DIGEST_PATTERN, "source.item_revision"),
|
|
307
|
+
liked_evidence_ids: evidenceIds,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (value.kind === "external_recipe") {
|
|
311
|
+
assertExactKeys(value, new Set(["kind", "title", "canonical_url", "site_name", "discovered_at"]), "source");
|
|
312
|
+
return {
|
|
313
|
+
kind: "external_recipe",
|
|
314
|
+
title: assertBoundedText(value.title, "source.title", 300),
|
|
315
|
+
canonical_url: assertHttpsUrl(value.canonical_url, "source.canonical_url"),
|
|
316
|
+
site_name: assertBoundedText(value.site_name, "source.site_name", 200),
|
|
317
|
+
discovered_at: assertIsoDateTime(value.discovered_at, "source.discovered_at"),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
fail("VALIDATION_FAILED", "source kind is unsupported");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function localRecipeContentDigest(item) {
|
|
324
|
+
return `sha256:${createHash("sha256").update(stableJson(item)).digest("hex")}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function validateLocalMealProposalSource(source, journal) {
|
|
328
|
+
if (source.kind !== "journal_recipe") return;
|
|
329
|
+
const item = Array.isArray(journal.items)
|
|
330
|
+
? journal.items.find((candidate) => isPlainObject(candidate) && candidate.id === source.item_id)
|
|
331
|
+
: undefined;
|
|
332
|
+
if (item === undefined || item.kind !== "recipe") {
|
|
333
|
+
fail("VALIDATION_FAILED", "a journal recipe proposal must cite an existing recipe");
|
|
334
|
+
}
|
|
335
|
+
if (localRecipeContentDigest(item) !== source.item_revision) {
|
|
336
|
+
fail("LOCAL_RECIPE_REVISION_CONFLICT", "the cited recipe revision is no longer current");
|
|
337
|
+
}
|
|
338
|
+
if (item.liked !== "yes" || !Array.isArray(item.evidence_ids)) {
|
|
339
|
+
fail("VALIDATION_FAILED", "a liked-recipe proposal requires current Liked evidence");
|
|
340
|
+
}
|
|
341
|
+
const itemEvidenceIds = new Set(item.evidence_ids);
|
|
342
|
+
const evidence = Array.isArray(journal.evidence) ? journal.evidence : [];
|
|
343
|
+
const invalidEvidence = source.liked_evidence_ids.some((id) => {
|
|
344
|
+
const cited = evidence.find((candidate) => isPlainObject(candidate) && candidate.id === id);
|
|
345
|
+
return !itemEvidenceIds.has(id)
|
|
346
|
+
|| cited?.kind !== "user_confirmation"
|
|
347
|
+
|| cited.confirmation?.subject !== "recipe_preference"
|
|
348
|
+
|| cited.confirmation.recipe_item_id !== item.id
|
|
349
|
+
|| cited.confirmation.preference !== "liked";
|
|
350
|
+
});
|
|
351
|
+
if (invalidEvidence) fail("VALIDATION_FAILED", "Liked evidence must be a cited user confirmation");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function assertMealPlanningState(value) {
|
|
355
|
+
if (!isPlainObject(value)) fail("VALIDATION_FAILED", "journal.meal_planning must be an object");
|
|
356
|
+
assertExactKeys(value, new Set(["schema_version", "profile", "proposals", "events", "idempotency"]), "journal.meal_planning");
|
|
357
|
+
if (value.schema_version !== 1) fail("VALIDATION_FAILED", "journal.meal_planning schema version is unsupported");
|
|
358
|
+
if (!isPlainObject(value.profile)) fail("VALIDATION_FAILED", "journal.meal_planning.profile must be an object");
|
|
359
|
+
assertExactKeys(value.profile, new Set([
|
|
360
|
+
"schema_version",
|
|
361
|
+
"revision",
|
|
362
|
+
"constraints",
|
|
363
|
+
"updated_at",
|
|
364
|
+
"updated_by",
|
|
365
|
+
]), "journal.meal_planning.profile");
|
|
366
|
+
if (value.profile.schema_version !== 1) fail("VALIDATION_FAILED", "meal-planning profile schema version is unsupported");
|
|
367
|
+
assertRevision(value.profile.revision, "meal-planning profile revision");
|
|
368
|
+
assertMealConstraints(value.profile.constraints);
|
|
369
|
+
assertIsoDateTime(value.profile.updated_at, "meal-planning profile updated_at");
|
|
370
|
+
if (!isPlainObject(value.profile.updated_by)) fail("VALIDATION_FAILED", "meal-planning profile updated_by must be an object");
|
|
371
|
+
assertExactKeys(value.profile.updated_by, new Set(["kind", "label"]), "meal-planning profile updated_by");
|
|
372
|
+
if (value.profile.updated_by.kind !== "local") fail("VALIDATION_FAILED", "meal-planning profile actor must be local");
|
|
373
|
+
assertBoundedText(value.profile.updated_by.label, "meal-planning profile actor label", 80);
|
|
374
|
+
|
|
375
|
+
if (!Array.isArray(value.proposals) || value.proposals.length > MAX_MEAL_PROPOSALS) {
|
|
376
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals");
|
|
377
|
+
}
|
|
378
|
+
if (!Array.isArray(value.events) || value.events.length > MAX_MEAL_EVENTS) {
|
|
379
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events");
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(value.idempotency) || value.idempotency.length > MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
|
|
382
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const proposalIds = new Set();
|
|
386
|
+
const proposalsByWeek = new Map();
|
|
387
|
+
const proposalsBySlot = new Map();
|
|
388
|
+
for (const proposal of value.proposals) {
|
|
389
|
+
assertStoredMealProposal(proposal);
|
|
390
|
+
if (proposalIds.has(proposal.id)) fail("VALIDATION_FAILED", "meal-planning proposal IDs must be unique");
|
|
391
|
+
proposalIds.add(proposal.id);
|
|
392
|
+
proposalsByWeek.set(proposal.week_start, (proposalsByWeek.get(proposal.week_start) ?? 0) + 1);
|
|
393
|
+
const slotKey = `${proposal.week_start}:${proposal.meal_date}:${mealSlotKey(proposal.slot)}`;
|
|
394
|
+
proposalsBySlot.set(slotKey, (proposalsBySlot.get(slotKey) ?? 0) + 1);
|
|
395
|
+
}
|
|
396
|
+
if ([...proposalsByWeek.values()].some((count) => count > MAX_MEAL_PROPOSALS_PER_WEEK)) {
|
|
397
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one week");
|
|
398
|
+
}
|
|
399
|
+
if ([...proposalsBySlot.values()].some((count) => count > MAX_MEAL_PROPOSALS_PER_SLOT)) {
|
|
400
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one meal slot");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const eventIds = new Set();
|
|
404
|
+
const reviewEventsByWeek = new Map();
|
|
405
|
+
const withdrawalEventsByWeek = new Map();
|
|
406
|
+
let reviewEventCount = 0;
|
|
407
|
+
let withdrawalEventCount = 0;
|
|
408
|
+
for (const event of value.events) {
|
|
409
|
+
assertStoredMealEvent(event);
|
|
410
|
+
if (eventIds.has(event.id)) fail("VALIDATION_FAILED", "meal-planning event IDs must be unique");
|
|
411
|
+
eventIds.add(event.id);
|
|
412
|
+
const byWeek = event.kind === "constraints_reviewed" ? reviewEventsByWeek : withdrawalEventsByWeek;
|
|
413
|
+
byWeek.set(event.week_start, (byWeek.get(event.week_start) ?? 0) + 1);
|
|
414
|
+
if (event.kind === "constraints_reviewed") reviewEventCount += 1;
|
|
415
|
+
else withdrawalEventCount += 1;
|
|
416
|
+
}
|
|
417
|
+
if (reviewEventCount > MAX_MEAL_REVIEW_EVENTS || withdrawalEventCount > MAX_MEAL_WITHDRAWAL_EVENTS) {
|
|
418
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events");
|
|
419
|
+
}
|
|
420
|
+
if ([...reviewEventsByWeek.values()].some((count) => count > MAX_MEAL_REVIEW_EVENTS_PER_WEEK)
|
|
421
|
+
|| [...withdrawalEventsByWeek.values()].some((count) => count > MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK)) {
|
|
422
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events of one kind for one week");
|
|
423
|
+
}
|
|
424
|
+
const weeks = new Set([...proposalsByWeek.keys(), ...reviewEventsByWeek.keys(), ...withdrawalEventsByWeek.keys()]);
|
|
425
|
+
if (weeks.size > MAX_MEAL_WEEKS) {
|
|
426
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many weeks");
|
|
427
|
+
}
|
|
428
|
+
for (const proposal of value.proposals) {
|
|
429
|
+
const review = value.events.find(({ id }) => id === proposal.constraint_review_event_id);
|
|
430
|
+
if (review?.kind !== "constraints_reviewed"
|
|
431
|
+
|| review.week_start !== proposal.week_start
|
|
432
|
+
|| review.constraint_revision !== proposal.constraint_revision) {
|
|
433
|
+
fail("VALIDATION_FAILED", "meal proposal does not reference its matching weekly constraint review");
|
|
434
|
+
}
|
|
435
|
+
if (proposal.constraint_revision > value.profile.revision) {
|
|
436
|
+
fail("VALIDATION_FAILED", "meal proposal references a future constraint revision");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
for (const event of value.events) {
|
|
440
|
+
if (event.kind === "constraints_reviewed" && event.constraint_revision > value.profile.revision) {
|
|
441
|
+
fail("VALIDATION_FAILED", "meal-plan event references a future constraint revision");
|
|
442
|
+
}
|
|
443
|
+
if (event.kind === "proposal_withdrawn"
|
|
444
|
+
&& !value.proposals.some(({ id, week_start: weekStart }) => id === event.proposal_id && weekStart === event.week_start)) {
|
|
445
|
+
fail("VALIDATION_FAILED", "withdrawal event does not reference a proposal in its week");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const receiptKeys = new Set();
|
|
449
|
+
for (const receipt of value.idempotency) {
|
|
450
|
+
if (!isPlainObject(receipt)) fail("VALIDATION_FAILED", "meal-planning idempotency receipt must be an object");
|
|
451
|
+
assertExactKeys(receipt, new Set(["key", "kind", "fingerprint", "entity_id"]), "meal-planning idempotency receipt");
|
|
452
|
+
assertIdempotencyKey(receipt.key);
|
|
453
|
+
if (!["meal_planning_profile", "constraints_reviewed", "meal_proposal", "meal_plan_event"].includes(receipt.kind)) {
|
|
454
|
+
fail("VALIDATION_FAILED", "meal-planning idempotency receipt kind is unsupported");
|
|
455
|
+
}
|
|
456
|
+
if (typeof receipt.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(receipt.fingerprint)) {
|
|
457
|
+
fail("VALIDATION_FAILED", "meal-planning idempotency fingerprint is invalid");
|
|
458
|
+
}
|
|
459
|
+
const expectedPattern = receipt.kind === "meal_planning_profile"
|
|
460
|
+
? MEAL_PROFILE_RECEIPT_ID_PATTERN
|
|
461
|
+
: receipt.kind === "meal_proposal"
|
|
462
|
+
? MEAL_PROPOSAL_ID_PATTERN
|
|
463
|
+
: MEAL_EVENT_ID_PATTERN;
|
|
464
|
+
assertOpaqueId(receipt.entity_id, expectedPattern, "meal-planning idempotency entity_id");
|
|
465
|
+
if (receipt.kind !== "meal_planning_profile") {
|
|
466
|
+
const entity = receipt.kind === "meal_proposal"
|
|
467
|
+
? value.proposals.find(({ id }) => id === receipt.entity_id)
|
|
468
|
+
: value.events.find(({ id }) => id === receipt.entity_id);
|
|
469
|
+
if (entity === undefined
|
|
470
|
+
|| (receipt.kind === "constraints_reviewed" && entity.kind !== "constraints_reviewed")
|
|
471
|
+
|| (receipt.kind === "meal_plan_event" && entity.kind !== "proposal_withdrawn")) {
|
|
472
|
+
fail("VALIDATION_FAILED", "meal-planning idempotency receipt has no matching entity");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (receiptKeys.has(receipt.key)) fail("VALIDATION_FAILED", "meal-planning idempotency keys must be unique");
|
|
476
|
+
receiptKeys.add(receipt.key);
|
|
477
|
+
}
|
|
478
|
+
return value;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function assertStoredMealProposal(value) {
|
|
482
|
+
if (!isPlainObject(value)) fail("VALIDATION_FAILED", "meal proposal must be an object");
|
|
483
|
+
assertExactKeys(value, new Set([
|
|
484
|
+
"id",
|
|
485
|
+
"week_start",
|
|
486
|
+
"meal_date",
|
|
487
|
+
"slot",
|
|
488
|
+
"proposed_by",
|
|
489
|
+
"source",
|
|
490
|
+
"servings",
|
|
491
|
+
"notes",
|
|
492
|
+
"constraint_revision",
|
|
493
|
+
"constraint_review_event_id",
|
|
494
|
+
"compatibility",
|
|
495
|
+
"compatibility_caveat",
|
|
496
|
+
"created_at",
|
|
497
|
+
"schema_version",
|
|
498
|
+
]), "meal proposal");
|
|
499
|
+
if (value.schema_version !== 1) fail("VALIDATION_FAILED", "meal proposal schema version is unsupported");
|
|
500
|
+
assertOpaqueId(value.id, MEAL_PROPOSAL_ID_PATTERN, "meal proposal ID");
|
|
501
|
+
const weekStart = assertMonday(value.week_start);
|
|
502
|
+
assertMealDate(weekStart, value.meal_date);
|
|
503
|
+
assertMealSlot(value.slot);
|
|
504
|
+
if (!isPlainObject(value.proposed_by) || value.proposed_by.kind !== "local") fail("VALIDATION_FAILED", "local meal proposal actor is invalid");
|
|
505
|
+
assertExactKeys(value.proposed_by, new Set(["kind", "label"]), "meal proposal actor");
|
|
506
|
+
assertBoundedText(value.proposed_by.label, "meal proposal actor label", 80);
|
|
507
|
+
assertLocalMealSource(value.source);
|
|
508
|
+
if (value.servings !== null && (!Number.isSafeInteger(value.servings) || value.servings < 1 || value.servings > 100)) {
|
|
509
|
+
fail("VALIDATION_FAILED", "servings must be null or an integer from 1 to 100");
|
|
510
|
+
}
|
|
511
|
+
assertBoundedText(value.notes, "notes", 1_000, { nullable: true });
|
|
512
|
+
assertRevision(value.constraint_revision, "constraint_revision");
|
|
513
|
+
assertOpaqueId(value.constraint_review_event_id, MEAL_EVENT_ID_PATTERN, "constraint_review_event_id");
|
|
514
|
+
if (!["appears_compatible", "incomplete_evidence", "needs_recheck"].includes(value.compatibility)) {
|
|
515
|
+
fail("VALIDATION_FAILED", "compatibility is unsupported");
|
|
516
|
+
}
|
|
517
|
+
assertBoundedText(value.compatibility_caveat, "compatibility_caveat", 1_000);
|
|
518
|
+
assertIsoDateTime(value.created_at, "meal proposal created_at");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function assertStoredMealEvent(value) {
|
|
522
|
+
if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "meal-plan event must be an object");
|
|
523
|
+
const common = ["id", "kind", "week_start", "actor", "occurred_at", "schema_version"];
|
|
524
|
+
const specific = value.kind === "constraints_reviewed"
|
|
525
|
+
? ["constraint_revision"]
|
|
526
|
+
: value.kind === "proposal_withdrawn"
|
|
527
|
+
? ["proposal_id", "reason"]
|
|
528
|
+
: [];
|
|
529
|
+
if (specific.length === 0) fail("VALIDATION_FAILED", "meal-plan event kind is unsupported");
|
|
530
|
+
assertExactKeys(value, new Set([...common, ...specific]), "meal-plan event");
|
|
531
|
+
if (value.schema_version !== 1) fail("VALIDATION_FAILED", "meal-plan event schema version is unsupported");
|
|
532
|
+
assertOpaqueId(value.id, MEAL_EVENT_ID_PATTERN, "meal-plan event ID");
|
|
533
|
+
assertMonday(value.week_start);
|
|
534
|
+
if (!isPlainObject(value.actor) || value.actor.kind !== "local") fail("VALIDATION_FAILED", "meal-plan event actor is invalid");
|
|
535
|
+
assertExactKeys(value.actor, new Set(["kind", "label"]), "meal-plan event actor");
|
|
536
|
+
assertBoundedText(value.actor.label, "meal-plan event actor label", 80);
|
|
537
|
+
assertIsoDateTime(value.occurred_at, "meal-plan event occurred_at");
|
|
538
|
+
if (value.kind === "constraints_reviewed") assertRevision(value.constraint_revision, "constraint_revision");
|
|
539
|
+
if (value.kind === "proposal_withdrawn") {
|
|
540
|
+
assertOpaqueId(value.proposal_id, MEAL_PROPOSAL_ID_PATTERN, "proposal_id");
|
|
541
|
+
assertBoundedText(value.reason, "reason", 500, { nullable: true });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
94
545
|
function assertJournal(value) {
|
|
95
546
|
if (!isPlainObject(value)) fail("VALIDATION_FAILED", "journal must be an object");
|
|
547
|
+
if (value.household !== undefined) {
|
|
548
|
+
if (!isPlainObject(value.household)) fail("VALIDATION_FAILED", "journal household must be an object");
|
|
549
|
+
assertExactKeys(value.household, new Set(["display_name"]), "journal household");
|
|
550
|
+
assertBoundedText(value.household.display_name, "household display name", 120);
|
|
551
|
+
}
|
|
96
552
|
if (Array.isArray(value.evidence) && value.evidence.length > 10_000) {
|
|
97
553
|
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "journal exceeds 10,000 evidence records");
|
|
98
554
|
}
|
|
99
555
|
if (Array.isArray(value.items) && value.items.length > 10_000) {
|
|
100
556
|
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "journal exceeds 10,000 items");
|
|
101
557
|
}
|
|
558
|
+
if (value.meal_planning !== undefined) assertMealPlanningState(value.meal_planning);
|
|
102
559
|
const pending = [{ value, depth: 0 }];
|
|
103
560
|
let nodes = 0;
|
|
104
561
|
while (pending.length > 0) {
|
|
@@ -182,9 +639,18 @@ function parseDocument(value) {
|
|
|
182
639
|
|
|
183
640
|
function parseRequest(input) {
|
|
184
641
|
if (!isPlainObject(input) || typeof input.operation !== "string") fail("VALIDATION_FAILED", "request must include an operation");
|
|
185
|
-
if (
|
|
186
|
-
assertExactKeys(input, new Set(["operation"]),
|
|
187
|
-
return {
|
|
642
|
+
if (input.operation === "initialize") {
|
|
643
|
+
assertExactKeys(input, new Set(["operation", "household_name"]), "initialize request");
|
|
644
|
+
return {
|
|
645
|
+
operation: "initialize",
|
|
646
|
+
household_name: input.household_name === undefined
|
|
647
|
+
? undefined
|
|
648
|
+
: assertHouseholdName(input.household_name),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (input.operation === "load") {
|
|
652
|
+
assertExactKeys(input, new Set(["operation"]), "load request");
|
|
653
|
+
return { operation: "load" };
|
|
188
654
|
}
|
|
189
655
|
if (input.operation === "save") {
|
|
190
656
|
assertExactKeys(input, new Set(["operation", "expected_revision", "journal"]), "save request");
|
|
@@ -194,6 +660,117 @@ function parseRequest(input) {
|
|
|
194
660
|
journal: assertJournal(input.journal),
|
|
195
661
|
};
|
|
196
662
|
}
|
|
663
|
+
if (input.operation === "rename_household") {
|
|
664
|
+
assertExactKeys(input, new Set(["operation", "expected_revision", "household_name"]), "rename_household request");
|
|
665
|
+
return {
|
|
666
|
+
operation: "rename_household",
|
|
667
|
+
expected_revision: assertRevision(input.expected_revision),
|
|
668
|
+
household_name: assertHouseholdName(input.household_name),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
if (input.operation === "save_meal_planning_profile") {
|
|
672
|
+
assertExactKeys(input, new Set([
|
|
673
|
+
"operation",
|
|
674
|
+
"expected_revision",
|
|
675
|
+
"idempotency_key",
|
|
676
|
+
"actor_label",
|
|
677
|
+
"constraints",
|
|
678
|
+
]), "save_meal_planning_profile request");
|
|
679
|
+
const constraints = assertMealConstraints(input.constraints);
|
|
680
|
+
if (constraints.status === "unresolved") fail("VALIDATION_FAILED", "meal-planning constraints must record an explicit answer");
|
|
681
|
+
return {
|
|
682
|
+
operation: "save_meal_planning_profile",
|
|
683
|
+
expected_revision: assertRevision(input.expected_revision),
|
|
684
|
+
idempotency_key: assertIdempotencyKey(input.idempotency_key),
|
|
685
|
+
actor: assertLocalActor(input.actor_label),
|
|
686
|
+
constraints,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if (input.operation === "review_meal_constraints") {
|
|
690
|
+
assertExactKeys(input, new Set([
|
|
691
|
+
"operation",
|
|
692
|
+
"expected_revision",
|
|
693
|
+
"idempotency_key",
|
|
694
|
+
"actor_label",
|
|
695
|
+
"week_start",
|
|
696
|
+
"constraint_revision",
|
|
697
|
+
]), "review_meal_constraints request");
|
|
698
|
+
return {
|
|
699
|
+
operation: "review_meal_constraints",
|
|
700
|
+
expected_revision: assertRevision(input.expected_revision),
|
|
701
|
+
idempotency_key: assertIdempotencyKey(input.idempotency_key),
|
|
702
|
+
actor: assertLocalActor(input.actor_label),
|
|
703
|
+
week_start: assertMonday(input.week_start),
|
|
704
|
+
constraint_revision: assertRevision(input.constraint_revision, "constraint_revision"),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (input.operation === "append_meal_proposal") {
|
|
708
|
+
assertExactKeys(input, new Set([
|
|
709
|
+
"operation",
|
|
710
|
+
"expected_revision",
|
|
711
|
+
"idempotency_key",
|
|
712
|
+
"actor_label",
|
|
713
|
+
"week_start",
|
|
714
|
+
"meal_date",
|
|
715
|
+
"slot",
|
|
716
|
+
"source",
|
|
717
|
+
"servings",
|
|
718
|
+
"notes",
|
|
719
|
+
"constraint_revision",
|
|
720
|
+
"constraint_review_event_id",
|
|
721
|
+
"compatibility",
|
|
722
|
+
"compatibility_caveat",
|
|
723
|
+
]), "append_meal_proposal request");
|
|
724
|
+
const weekStart = assertMonday(input.week_start);
|
|
725
|
+
if (input.servings !== null && (!Number.isSafeInteger(input.servings) || input.servings < 1 || input.servings > 100)) {
|
|
726
|
+
fail("VALIDATION_FAILED", "servings must be null or an integer from 1 to 100");
|
|
727
|
+
}
|
|
728
|
+
if (!["appears_compatible", "incomplete_evidence", "needs_recheck"].includes(input.compatibility)) {
|
|
729
|
+
fail("VALIDATION_FAILED", "compatibility is unsupported");
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
operation: "append_meal_proposal",
|
|
733
|
+
expected_revision: assertRevision(input.expected_revision),
|
|
734
|
+
idempotency_key: assertIdempotencyKey(input.idempotency_key),
|
|
735
|
+
actor: assertLocalActor(input.actor_label),
|
|
736
|
+
week_start: weekStart,
|
|
737
|
+
meal_date: assertMealDate(weekStart, input.meal_date),
|
|
738
|
+
slot: assertMealSlot(input.slot),
|
|
739
|
+
source: assertLocalMealSource(input.source),
|
|
740
|
+
servings: input.servings,
|
|
741
|
+
notes: assertBoundedText(input.notes, "notes", 1_000, { nullable: true }),
|
|
742
|
+
constraint_revision: assertRevision(input.constraint_revision, "constraint_revision"),
|
|
743
|
+
constraint_review_event_id: assertOpaqueId(input.constraint_review_event_id, MEAL_EVENT_ID_PATTERN, "constraint_review_event_id"),
|
|
744
|
+
compatibility: input.compatibility,
|
|
745
|
+
compatibility_caveat: assertBoundedText(input.compatibility_caveat, "compatibility_caveat", 1_000),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (input.operation === "record_meal_plan_event") {
|
|
749
|
+
assertExactKeys(input, new Set([
|
|
750
|
+
"operation",
|
|
751
|
+
"expected_revision",
|
|
752
|
+
"idempotency_key",
|
|
753
|
+
"actor_label",
|
|
754
|
+
"week_start",
|
|
755
|
+
"event",
|
|
756
|
+
]), "record_meal_plan_event request");
|
|
757
|
+
if (!isPlainObject(input.event) || input.event.kind !== "proposal_withdrawn") {
|
|
758
|
+
fail("VALIDATION_FAILED", "record_meal_plan_event supports only proposal_withdrawn");
|
|
759
|
+
}
|
|
760
|
+
assertExactKeys(input.event, new Set(["kind", "proposal_id", "reason"]), "record_meal_plan_event event");
|
|
761
|
+
return {
|
|
762
|
+
operation: "record_meal_plan_event",
|
|
763
|
+
expected_revision: assertRevision(input.expected_revision),
|
|
764
|
+
idempotency_key: assertIdempotencyKey(input.idempotency_key),
|
|
765
|
+
actor: assertLocalActor(input.actor_label),
|
|
766
|
+
week_start: assertMonday(input.week_start),
|
|
767
|
+
event: {
|
|
768
|
+
kind: "proposal_withdrawn",
|
|
769
|
+
proposal_id: assertOpaqueId(input.event.proposal_id, MEAL_PROPOSAL_ID_PATTERN, "proposal_id"),
|
|
770
|
+
reason: assertBoundedText(input.event.reason, "reason", 500, { nullable: true }),
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
197
774
|
if (["finalize", "delete_collecting"].includes(input.operation)) {
|
|
198
775
|
assertExactKeys(input, new Set(["operation", "expected_revision"]), `${input.operation} request`);
|
|
199
776
|
return { operation: input.operation, expected_revision: assertRevision(input.expected_revision) };
|
|
@@ -250,7 +827,7 @@ async function readDocument(filePath) {
|
|
|
250
827
|
}
|
|
251
828
|
}
|
|
252
829
|
|
|
253
|
-
async function ensurePrivateDirectory(root, directory) {
|
|
830
|
+
export async function ensurePrivateDirectory(root, directory) {
|
|
254
831
|
const resolvedRoot = path.resolve(root);
|
|
255
832
|
const relative = path.relative(resolvedRoot, directory);
|
|
256
833
|
if (relative.startsWith("..") || path.isAbsolute(relative)) fail("VALIDATION_FAILED", "local household directory escapes the Codex home");
|
|
@@ -264,50 +841,205 @@ async function ensurePrivateDirectory(root, directory) {
|
|
|
264
841
|
}
|
|
265
842
|
} catch (error) {
|
|
266
843
|
if (error?.code !== "ENOENT") throw error;
|
|
267
|
-
|
|
844
|
+
try {
|
|
845
|
+
await mkdir(current, { mode: 0o700 });
|
|
846
|
+
} catch (mkdirError) {
|
|
847
|
+
if (mkdirError?.code !== "EEXIST") throw mkdirError;
|
|
848
|
+
const concurrentStat = await lstat(current);
|
|
849
|
+
if (!concurrentStat.isDirectory() || concurrentStat.isSymbolicLink()) {
|
|
850
|
+
fail("UNSAFE_LOCAL_PATH", "local household path contains a non-directory or symbolic link");
|
|
851
|
+
}
|
|
852
|
+
}
|
|
268
853
|
}
|
|
269
854
|
await chmod(current, 0o700);
|
|
270
855
|
}
|
|
271
856
|
}
|
|
272
857
|
|
|
273
|
-
|
|
274
|
-
|
|
858
|
+
/**
|
|
859
|
+
* Acquires a private local-state lock.
|
|
860
|
+
*
|
|
861
|
+
* Purpose-specific fan-in operations may opt into a bounded wait so concurrent
|
|
862
|
+
* appends serialize. A short-lived guard serializes lease replacement so stale
|
|
863
|
+
* recovery cannot unlink a newly acquired lock through a pathname ABA race.
|
|
864
|
+
*/
|
|
865
|
+
async function writeLockLease(lockPath, token, now) {
|
|
866
|
+
const handle = await open(lockPath, "wx", 0o600);
|
|
867
|
+
try {
|
|
868
|
+
await handle.writeFile(JSON.stringify({
|
|
869
|
+
token,
|
|
870
|
+
pid: process.pid,
|
|
871
|
+
created_at: now.toISOString(),
|
|
872
|
+
}));
|
|
873
|
+
await handle.sync();
|
|
874
|
+
} finally {
|
|
875
|
+
await handle.close();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export async function acquireLocalLock(
|
|
880
|
+
directory,
|
|
881
|
+
now,
|
|
882
|
+
{ lockName = ".household.lock", waitForLiveWriter = false } = {},
|
|
883
|
+
) {
|
|
884
|
+
if (!/^\.[a-z0-9-]+\.lock$/.test(lockName)) fail("VALIDATION_FAILED", "local lock name is invalid");
|
|
885
|
+
const lockPath = path.join(directory, lockName);
|
|
275
886
|
const token = randomUUID();
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
887
|
+
const waitDeadline = Date.now() + LOCK_WAIT_MS;
|
|
888
|
+
while (true) {
|
|
889
|
+
const acquired = await withLockGuard(lockPath, waitForLiveWriter, async () => {
|
|
890
|
+
try {
|
|
891
|
+
await writeLockLease(lockPath, token, now);
|
|
892
|
+
return true;
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if (error?.code !== "EEXIST") throw error;
|
|
895
|
+
}
|
|
285
896
|
let existing;
|
|
286
897
|
try {
|
|
287
898
|
existing = JSON.parse(await readFile(lockPath, "utf8"));
|
|
288
|
-
} catch {
|
|
899
|
+
} catch (error) {
|
|
900
|
+
let lockStat;
|
|
901
|
+
try {
|
|
902
|
+
lockStat = await lstat(lockPath);
|
|
903
|
+
} catch (statError) {
|
|
904
|
+
if (statError?.code !== "ENOENT") throw statError;
|
|
905
|
+
await writeLockLease(lockPath, token, now);
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
if (!lockStat.isFile() || lockStat.isSymbolicLink()) {
|
|
909
|
+
fail("UNSAFE_LOCAL_PATH", "local lock is not a private regular file");
|
|
910
|
+
}
|
|
911
|
+
if (now.getTime() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
912
|
+
await unlink(lockPath);
|
|
913
|
+
await writeLockLease(lockPath, token, now);
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
289
917
|
fail("LOCAL_HOUSEHOLD_BUSY", "the local household is locked");
|
|
290
918
|
}
|
|
291
919
|
const createdAt = typeof existing?.created_at === "string" ? Date.parse(existing.created_at) : Number.NaN;
|
|
292
|
-
if (!Number.isFinite(createdAt)
|
|
920
|
+
if (!Number.isFinite(createdAt)) {
|
|
293
921
|
fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
|
|
294
922
|
}
|
|
295
|
-
|
|
296
|
-
|
|
923
|
+
const ownerIsAlive = Number.isSafeInteger(existing.pid) && processIsAlive(existing.pid);
|
|
924
|
+
if (now.getTime() - createdAt > LOCK_STALE_MS && !ownerIsAlive) {
|
|
925
|
+
await unlink(lockPath);
|
|
926
|
+
await writeLockLease(lockPath, token, now);
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
return false;
|
|
930
|
+
});
|
|
931
|
+
if (acquired) return { lockPath, token };
|
|
932
|
+
if (!waitForLiveWriter) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
|
|
933
|
+
if (Date.now() >= waitDeadline) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
|
|
934
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, LOCK_RETRY_MS));
|
|
297
935
|
}
|
|
298
|
-
fail("LOCAL_HOUSEHOLD_BUSY", "the local household could not be locked");
|
|
299
936
|
}
|
|
300
937
|
|
|
301
|
-
|
|
938
|
+
function processIsAlive(pid) {
|
|
302
939
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
940
|
+
process.kill(pid, 0);
|
|
941
|
+
return true;
|
|
305
942
|
} catch (error) {
|
|
306
|
-
|
|
943
|
+
return error?.code === "EPERM";
|
|
307
944
|
}
|
|
308
945
|
}
|
|
309
946
|
|
|
310
|
-
async function
|
|
947
|
+
async function readGuardOwner(guardPath) {
|
|
948
|
+
const guardStat = await lstat(guardPath);
|
|
949
|
+
if (!guardStat.isDirectory() || guardStat.isSymbolicLink()) {
|
|
950
|
+
fail("UNSAFE_LOCAL_PATH", "local lock guard is not a private directory");
|
|
951
|
+
}
|
|
952
|
+
let handle;
|
|
953
|
+
try {
|
|
954
|
+
handle = await open(path.join(guardPath, "owner.json"), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
955
|
+
const owner = JSON.parse(await handle.readFile("utf8"));
|
|
956
|
+
if (typeof owner?.token !== "string"
|
|
957
|
+
|| !/^[0-9a-f-]{36}$/.test(owner.token)
|
|
958
|
+
|| !Number.isSafeInteger(owner.pid)
|
|
959
|
+
|| typeof owner.created_at !== "string"
|
|
960
|
+
|| !Number.isFinite(Date.parse(owner.created_at))) {
|
|
961
|
+
fail("LOCAL_HOUSEHOLD_BUSY", "the local lock guard is invalid");
|
|
962
|
+
}
|
|
963
|
+
return owner;
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
966
|
+
return {
|
|
967
|
+
token: `orphan-${guardStat.ino}-${Math.trunc(guardStat.mtimeMs)}`,
|
|
968
|
+
pid: null,
|
|
969
|
+
created_at: guardStat.mtime.toISOString(),
|
|
970
|
+
};
|
|
971
|
+
} finally {
|
|
972
|
+
await handle?.close();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function withLockGuard(lockPath, waitForLiveWriter, apply) {
|
|
977
|
+
const guardPath = `${lockPath}.guard`;
|
|
978
|
+
const waitDeadline = Date.now() + LOCK_WAIT_MS;
|
|
979
|
+
const token = randomUUID();
|
|
980
|
+
while (true) {
|
|
981
|
+
try {
|
|
982
|
+
await mkdir(guardPath, { mode: 0o700 });
|
|
983
|
+
const handle = await open(path.join(guardPath, "owner.json"), "wx", 0o600);
|
|
984
|
+
try {
|
|
985
|
+
await handle.writeFile(JSON.stringify({
|
|
986
|
+
token,
|
|
987
|
+
pid: process.pid,
|
|
988
|
+
created_at: new Date().toISOString(),
|
|
989
|
+
}));
|
|
990
|
+
await handle.sync();
|
|
991
|
+
} finally {
|
|
992
|
+
await handle.close();
|
|
993
|
+
}
|
|
994
|
+
break;
|
|
995
|
+
} catch (error) {
|
|
996
|
+
if (error?.code !== "EEXIST") throw error;
|
|
997
|
+
let owner;
|
|
998
|
+
try {
|
|
999
|
+
owner = await readGuardOwner(guardPath);
|
|
1000
|
+
} catch (readError) {
|
|
1001
|
+
if (readError?.code === "ENOENT") continue;
|
|
1002
|
+
throw readError;
|
|
1003
|
+
}
|
|
1004
|
+
const createdAt = Date.parse(owner.created_at);
|
|
1005
|
+
if (Date.now() - createdAt > LOCK_STALE_MS
|
|
1006
|
+
&& (owner.pid === null || !processIsAlive(owner.pid))) {
|
|
1007
|
+
const retiredPath = `${guardPath}.retired-${owner.token}`;
|
|
1008
|
+
try {
|
|
1009
|
+
await rename(guardPath, retiredPath);
|
|
1010
|
+
continue;
|
|
1011
|
+
} catch (renameError) {
|
|
1012
|
+
if (!["EEXIST", "ENOTEMPTY", "ENOENT"].includes(renameError?.code)) throw renameError;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (!waitForLiveWriter) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
|
|
1016
|
+
if (Date.now() >= waitDeadline) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
|
|
1017
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, LOCK_RETRY_MS));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
return await apply();
|
|
1022
|
+
} finally {
|
|
1023
|
+
const owner = await readGuardOwner(guardPath);
|
|
1024
|
+
if (owner.token === token) {
|
|
1025
|
+
await unlink(path.join(guardPath, "owner.json"));
|
|
1026
|
+
await rmdir(guardPath);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export async function releaseLocalLock(lock) {
|
|
1032
|
+
await withLockGuard(lock.lockPath, true, async () => {
|
|
1033
|
+
try {
|
|
1034
|
+
const current = JSON.parse(await readFile(lock.lockPath, "utf8"));
|
|
1035
|
+
if (current?.token === lock.token) await unlink(lock.lockPath);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
export async function syncDirectory(directory) {
|
|
311
1043
|
const handle = await open(directory, constants.O_RDONLY);
|
|
312
1044
|
try {
|
|
313
1045
|
await handle.sync();
|
|
@@ -316,25 +1048,31 @@ async function syncDirectory(directory) {
|
|
|
316
1048
|
}
|
|
317
1049
|
}
|
|
318
1050
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
1051
|
+
/**
|
|
1052
|
+
* Atomically replaces one bounded private file below the active local root.
|
|
1053
|
+
*
|
|
1054
|
+
* Callers retain authority over the fixed destination; this helper enforces
|
|
1055
|
+
* confinement, private modes, durability, and no-symlink directories.
|
|
1056
|
+
*/
|
|
1057
|
+
export async function writePrivateFile(root, filePath, content, maximumBytes, label) {
|
|
1058
|
+
const resolvedRoot = path.resolve(root);
|
|
1059
|
+
const resolvedFile = path.resolve(filePath);
|
|
1060
|
+
const relative = path.relative(resolvedRoot, resolvedFile);
|
|
1061
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) fail("UNSAFE_LOCAL_PATH", `${label} path escapes the local root`);
|
|
1062
|
+
const directory = path.dirname(resolvedFile);
|
|
322
1063
|
await ensurePrivateDirectory(root, directory);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "local household exceeds 16 MiB");
|
|
326
|
-
}
|
|
327
|
-
const temporaryPath = path.join(directory, `.household.${process.pid}.${randomUUID()}.tmp`);
|
|
1064
|
+
if (typeof content !== "string" || Buffer.byteLength(content) > maximumBytes) fail("LOCAL_HOUSEHOLD_TOO_LARGE", `${label} exceeds its size limit`);
|
|
1065
|
+
const temporaryPath = path.join(directory, `.${path.basename(resolvedFile)}.${process.pid}.${randomUUID()}.tmp`);
|
|
328
1066
|
const handle = await open(temporaryPath, "wx", 0o600);
|
|
329
1067
|
try {
|
|
330
|
-
await handle.writeFile(
|
|
1068
|
+
await handle.writeFile(content);
|
|
331
1069
|
await handle.sync();
|
|
332
1070
|
} finally {
|
|
333
1071
|
await handle.close();
|
|
334
1072
|
}
|
|
335
1073
|
try {
|
|
336
|
-
await rename(temporaryPath,
|
|
337
|
-
await chmod(
|
|
1074
|
+
await rename(temporaryPath, resolvedFile);
|
|
1075
|
+
await chmod(resolvedFile, 0o600);
|
|
338
1076
|
await syncDirectory(directory);
|
|
339
1077
|
} catch (error) {
|
|
340
1078
|
try {
|
|
@@ -346,13 +1084,40 @@ async function writeDocument(root, document) {
|
|
|
346
1084
|
}
|
|
347
1085
|
}
|
|
348
1086
|
|
|
1087
|
+
async function writeDocument(root, document) {
|
|
1088
|
+
const serialized = `${JSON.stringify(document, null, 2)}\n`;
|
|
1089
|
+
await writePrivateFile(root, localHouseholdPath(root), serialized, MAX_DOCUMENT_BYTES, "local household");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
349
1092
|
function publicDocument(document) {
|
|
1093
|
+
const items = Array.isArray(document.journal.items) ? document.journal.items : [];
|
|
1094
|
+
const recipeContentRevisions = items.flatMap((item) =>
|
|
1095
|
+
isPlainObject(item) && item.kind === "recipe" && typeof item.id === "string" && ITEM_ID_PATTERN.test(item.id)
|
|
1096
|
+
? [{ item_id: item.id, item_revision: localRecipeContentDigest(item) }]
|
|
1097
|
+
: []);
|
|
1098
|
+
const planning = document.journal.meal_planning;
|
|
1099
|
+
const withdrawals = new Set(planning?.events.flatMap((event) =>
|
|
1100
|
+
event.kind === "proposal_withdrawn" ? [event.proposal_id] : []) ?? []);
|
|
1101
|
+
const mealProposalStatuses = planning?.proposals.map((proposal) => {
|
|
1102
|
+
const currentItemRevision = proposal.source.kind === "journal_recipe"
|
|
1103
|
+
? recipeContentRevisions.find(({ item_id: itemId }) => itemId === proposal.source.item_id)?.item_revision ?? null
|
|
1104
|
+
: null;
|
|
1105
|
+
const needsRecheck = proposal.constraint_revision !== planning.profile.revision
|
|
1106
|
+
|| (proposal.source.kind === "journal_recipe" && currentItemRevision !== proposal.source.item_revision);
|
|
1107
|
+
return {
|
|
1108
|
+
proposal_id: proposal.id,
|
|
1109
|
+
active: !withdrawals.has(proposal.id),
|
|
1110
|
+
effective_compatibility: needsRecheck ? "needs_recheck" : proposal.compatibility,
|
|
1111
|
+
};
|
|
1112
|
+
}) ?? [];
|
|
350
1113
|
return {
|
|
351
1114
|
local_household_id: document.local_household_id,
|
|
352
1115
|
promotion_idempotency_key: document.promotion_idempotency_key,
|
|
353
1116
|
state: document.state,
|
|
354
1117
|
revision: document.revision,
|
|
355
1118
|
journal: document.journal,
|
|
1119
|
+
recipe_content_revisions: recipeContentRevisions,
|
|
1120
|
+
meal_proposal_statuses: mealProposalStatuses,
|
|
356
1121
|
cloud_backup: document.cloud_backup,
|
|
357
1122
|
cloud_backup_current: document.cloud_backup?.local_revision === document.revision,
|
|
358
1123
|
};
|
|
@@ -362,7 +1127,7 @@ async function mutate(root, expectedRevision, now, apply) {
|
|
|
362
1127
|
const filePath = localHouseholdPath(root);
|
|
363
1128
|
const directory = path.dirname(filePath);
|
|
364
1129
|
await ensurePrivateDirectory(root, directory);
|
|
365
|
-
const lock = await
|
|
1130
|
+
const lock = await acquireLocalLock(directory, now);
|
|
366
1131
|
try {
|
|
367
1132
|
const current = await readDocument(filePath);
|
|
368
1133
|
if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
|
|
@@ -373,15 +1138,95 @@ async function mutate(root, expectedRevision, now, apply) {
|
|
|
373
1138
|
if (updated !== current) await writeDocument(root, updated);
|
|
374
1139
|
return updated;
|
|
375
1140
|
} finally {
|
|
376
|
-
await
|
|
1141
|
+
await releaseLocalLock(lock);
|
|
377
1142
|
}
|
|
378
1143
|
}
|
|
379
1144
|
|
|
380
|
-
|
|
1145
|
+
function requestFingerprint(value) {
|
|
1146
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function stableLocalEntityId(prefix, kind, idempotencyKey) {
|
|
1150
|
+
return `${prefix}_${createHash("sha256").update(`${kind}:${idempotencyKey}`).digest("hex").slice(0, 32)}`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function mealPlanningState(document) {
|
|
1154
|
+
const state = document.journal.meal_planning;
|
|
1155
|
+
if (state === undefined) fail("MEAL_PLANNING_PROFILE_REQUIRED", "record allergies and food sensitivities before planning meals");
|
|
1156
|
+
return state;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function replayedMealEntity(state, receipt) {
|
|
1160
|
+
const entities = receipt.kind === "meal_proposal" ? state.proposals : state.events;
|
|
1161
|
+
const entity = entities.find(({ id }) => id === receipt.entity_id);
|
|
1162
|
+
if (entity === undefined) fail("CORRUPT_LOCAL_HOUSEHOLD", "meal-planning idempotency receipt has no matching entity");
|
|
1163
|
+
return entity;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Serializes an append-only meal mutation against current nested state.
|
|
1168
|
+
*
|
|
1169
|
+
* The outer revision is an observation, not a last-writer-wins precondition:
|
|
1170
|
+
* after locking, current profile/review references are revalidated so two
|
|
1171
|
+
* independent proposals from the same starting revision can both commit.
|
|
1172
|
+
*/
|
|
1173
|
+
async function appendMealPlanningMutation(root, request, now, descriptor, apply) {
|
|
381
1174
|
const filePath = localHouseholdPath(root);
|
|
382
1175
|
const directory = path.dirname(filePath);
|
|
383
1176
|
await ensurePrivateDirectory(root, directory);
|
|
384
|
-
const lock = await
|
|
1177
|
+
const lock = await acquireLocalLock(directory, now, { waitForLiveWriter: true });
|
|
1178
|
+
try {
|
|
1179
|
+
const current = await readDocument(filePath);
|
|
1180
|
+
if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
|
|
1181
|
+
const state = mealPlanningState(current);
|
|
1182
|
+
const existingReceipt = state.idempotency.find(({ key }) => key === request.idempotency_key);
|
|
1183
|
+
if (existingReceipt !== undefined) {
|
|
1184
|
+
if (existingReceipt.kind !== descriptor.receiptKind || existingReceipt.fingerprint !== descriptor.fingerprint) {
|
|
1185
|
+
fail("IDEMPOTENCY_CONFLICT", "idempotency_key was already used for different meal-planning input");
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
status: "replayed",
|
|
1189
|
+
document: current,
|
|
1190
|
+
entity: replayedMealEntity(state, existingReceipt),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
if (current.revision < request.expected_revision) {
|
|
1194
|
+
fail("LOCAL_HOUSEHOLD_CONFLICT", `local household revision is ${current.revision}, before ${request.expected_revision}`);
|
|
1195
|
+
}
|
|
1196
|
+
if (state.idempotency.length >= MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
|
|
1197
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
|
|
1198
|
+
}
|
|
1199
|
+
const entity = apply(state, current.journal);
|
|
1200
|
+
const nextState = {
|
|
1201
|
+
...state,
|
|
1202
|
+
proposals: descriptor.receiptKind === "meal_proposal" ? [...state.proposals, entity] : state.proposals,
|
|
1203
|
+
events: descriptor.receiptKind === "meal_proposal" ? state.events : [...state.events, entity],
|
|
1204
|
+
idempotency: [...state.idempotency, {
|
|
1205
|
+
key: request.idempotency_key,
|
|
1206
|
+
kind: descriptor.receiptKind,
|
|
1207
|
+
fingerprint: descriptor.fingerprint,
|
|
1208
|
+
entity_id: entity.id,
|
|
1209
|
+
}],
|
|
1210
|
+
};
|
|
1211
|
+
const updated = {
|
|
1212
|
+
...current,
|
|
1213
|
+
revision: current.revision + 1,
|
|
1214
|
+
updated_at: now.toISOString(),
|
|
1215
|
+
journal: { ...current.journal, meal_planning: nextState },
|
|
1216
|
+
};
|
|
1217
|
+
assertJournal(updated.journal);
|
|
1218
|
+
await writeDocument(root, updated);
|
|
1219
|
+
return { status: descriptor.createdStatus, document: updated, entity };
|
|
1220
|
+
} finally {
|
|
1221
|
+
await releaseLocalLock(lock);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export async function initializeLocalHousehold(root, now = new Date(), householdName = undefined) {
|
|
1226
|
+
const filePath = localHouseholdPath(root);
|
|
1227
|
+
const directory = path.dirname(filePath);
|
|
1228
|
+
await ensurePrivateDirectory(root, directory);
|
|
1229
|
+
const lock = await acquireLocalLock(directory, now);
|
|
385
1230
|
try {
|
|
386
1231
|
const existing = await readDocument(filePath);
|
|
387
1232
|
if (existing !== null) return { status: "existing", ...publicDocument(existing) };
|
|
@@ -393,13 +1238,13 @@ export async function initializeLocalHousehold(root, now = new Date()) {
|
|
|
393
1238
|
revision: 1,
|
|
394
1239
|
created_at: now.toISOString(),
|
|
395
1240
|
updated_at: now.toISOString(),
|
|
396
|
-
journal: {},
|
|
1241
|
+
journal: householdName === undefined ? {} : { household: { display_name: householdName } },
|
|
397
1242
|
cloud_backup: null,
|
|
398
1243
|
};
|
|
399
1244
|
await writeDocument(root, document);
|
|
400
1245
|
return { status: "initialized", ...publicDocument(document) };
|
|
401
1246
|
} finally {
|
|
402
|
-
await
|
|
1247
|
+
await releaseLocalLock(lock);
|
|
403
1248
|
}
|
|
404
1249
|
}
|
|
405
1250
|
|
|
@@ -410,13 +1255,242 @@ export async function loadLocalHousehold(root) {
|
|
|
410
1255
|
|
|
411
1256
|
export async function saveLocalHousehold(root, input, now = new Date()) {
|
|
412
1257
|
const request = parseRequest({ ...input, operation: "save" });
|
|
1258
|
+
const document = await mutate(root, request.expected_revision, now, (current) => {
|
|
1259
|
+
const currentHousehold = current.journal.household;
|
|
1260
|
+
const requestedHousehold = request.journal.household;
|
|
1261
|
+
if (requestedHousehold !== undefined
|
|
1262
|
+
&& stableJson(requestedHousehold) !== stableJson(currentHousehold)) {
|
|
1263
|
+
fail("VALIDATION_FAILED", "use rename_household to change the local household name");
|
|
1264
|
+
}
|
|
1265
|
+
const currentMealPlanning = current.journal.meal_planning;
|
|
1266
|
+
const requestedMealPlanning = request.journal.meal_planning;
|
|
1267
|
+
if (currentMealPlanning === undefined && requestedMealPlanning !== undefined) {
|
|
1268
|
+
fail("VALIDATION_FAILED", "use the purpose-specific meal-planning operations");
|
|
1269
|
+
}
|
|
1270
|
+
if (currentMealPlanning !== undefined
|
|
1271
|
+
&& requestedMealPlanning !== undefined
|
|
1272
|
+
&& stableJson(currentMealPlanning) !== stableJson(requestedMealPlanning)) {
|
|
1273
|
+
fail("VALIDATION_FAILED", "use the purpose-specific meal-planning operations");
|
|
1274
|
+
}
|
|
1275
|
+
const journalWithMealPlanning = currentMealPlanning === undefined
|
|
1276
|
+
? request.journal
|
|
1277
|
+
: { ...request.journal, meal_planning: currentMealPlanning };
|
|
1278
|
+
const journal = currentHousehold === undefined
|
|
1279
|
+
? journalWithMealPlanning
|
|
1280
|
+
: { ...journalWithMealPlanning, household: currentHousehold };
|
|
1281
|
+
return {
|
|
1282
|
+
...current,
|
|
1283
|
+
revision: current.revision + 1,
|
|
1284
|
+
updated_at: now.toISOString(),
|
|
1285
|
+
journal,
|
|
1286
|
+
};
|
|
1287
|
+
});
|
|
1288
|
+
return { status: "saved", ...publicDocument(document) };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
export async function renameLocalHousehold(root, input, now = new Date()) {
|
|
1292
|
+
const request = parseRequest({ ...input, operation: "rename_household" });
|
|
413
1293
|
const document = await mutate(root, request.expected_revision, now, (current) => ({
|
|
414
1294
|
...current,
|
|
415
1295
|
revision: current.revision + 1,
|
|
416
1296
|
updated_at: now.toISOString(),
|
|
417
|
-
journal:
|
|
1297
|
+
journal: {
|
|
1298
|
+
...current.journal,
|
|
1299
|
+
household: { display_name: request.household_name },
|
|
1300
|
+
},
|
|
418
1301
|
}));
|
|
419
|
-
return { status: "
|
|
1302
|
+
return { status: "renamed", ...publicDocument(document) };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export async function saveLocalMealPlanningProfile(root, input, now = new Date()) {
|
|
1306
|
+
const request = parseRequest({ ...input, operation: "save_meal_planning_profile" });
|
|
1307
|
+
const fingerprint = requestFingerprint({
|
|
1308
|
+
operation: request.operation,
|
|
1309
|
+
actor: request.actor,
|
|
1310
|
+
constraints: request.constraints,
|
|
1311
|
+
});
|
|
1312
|
+
const filePath = localHouseholdPath(root);
|
|
1313
|
+
const directory = path.dirname(filePath);
|
|
1314
|
+
await ensurePrivateDirectory(root, directory);
|
|
1315
|
+
const lock = await acquireLocalLock(directory, now, { waitForLiveWriter: true });
|
|
1316
|
+
try {
|
|
1317
|
+
const current = await readDocument(filePath);
|
|
1318
|
+
if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
|
|
1319
|
+
const previous = current.journal.meal_planning;
|
|
1320
|
+
const existingReceipt = previous?.idempotency.find(({ key }) => key === request.idempotency_key);
|
|
1321
|
+
if (existingReceipt !== undefined) {
|
|
1322
|
+
if (existingReceipt.kind !== "meal_planning_profile" || existingReceipt.fingerprint !== fingerprint) {
|
|
1323
|
+
fail("IDEMPOTENCY_CONFLICT", "idempotency_key was already used for different meal-planning input");
|
|
1324
|
+
}
|
|
1325
|
+
return { status: "replayed", ...publicDocument(current) };
|
|
1326
|
+
}
|
|
1327
|
+
if (current.revision !== request.expected_revision) {
|
|
1328
|
+
fail("LOCAL_HOUSEHOLD_CONFLICT", `local household revision is ${current.revision}, not ${request.expected_revision}`);
|
|
1329
|
+
}
|
|
1330
|
+
if ((previous?.idempotency.length ?? 0) >= MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
|
|
1331
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
|
|
1332
|
+
}
|
|
1333
|
+
const profile = {
|
|
1334
|
+
schema_version: 1,
|
|
1335
|
+
revision: (previous?.profile.revision ?? 0) + 1,
|
|
1336
|
+
constraints: request.constraints,
|
|
1337
|
+
updated_at: now.toISOString(),
|
|
1338
|
+
updated_by: request.actor,
|
|
1339
|
+
};
|
|
1340
|
+
const receipt = {
|
|
1341
|
+
key: request.idempotency_key,
|
|
1342
|
+
kind: "meal_planning_profile",
|
|
1343
|
+
fingerprint,
|
|
1344
|
+
entity_id: stableLocalEntityId("mlr", request.operation, request.idempotency_key),
|
|
1345
|
+
};
|
|
1346
|
+
const mealPlanning = previous === undefined
|
|
1347
|
+
? { schema_version: 1, profile, proposals: [], events: [], idempotency: [receipt] }
|
|
1348
|
+
: { ...previous, profile, idempotency: [...previous.idempotency, receipt] };
|
|
1349
|
+
const journal = { ...current.journal, meal_planning: mealPlanning };
|
|
1350
|
+
assertJournal(journal);
|
|
1351
|
+
const document = {
|
|
1352
|
+
...current,
|
|
1353
|
+
revision: current.revision + 1,
|
|
1354
|
+
updated_at: now.toISOString(),
|
|
1355
|
+
journal,
|
|
1356
|
+
};
|
|
1357
|
+
await writeDocument(root, document);
|
|
1358
|
+
return { status: "meal_planning_profile_saved", ...publicDocument(document) };
|
|
1359
|
+
} finally {
|
|
1360
|
+
await releaseLocalLock(lock);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export async function reviewLocalMealConstraints(root, input, now = new Date()) {
|
|
1365
|
+
const request = parseRequest({ ...input, operation: "review_meal_constraints" });
|
|
1366
|
+
const fingerprint = requestFingerprint({
|
|
1367
|
+
operation: request.operation,
|
|
1368
|
+
actor: request.actor,
|
|
1369
|
+
week_start: request.week_start,
|
|
1370
|
+
constraint_revision: request.constraint_revision,
|
|
1371
|
+
});
|
|
1372
|
+
const result = await appendMealPlanningMutation(root, request, now, {
|
|
1373
|
+
receiptKind: "constraints_reviewed",
|
|
1374
|
+
fingerprint,
|
|
1375
|
+
createdStatus: "constraints_reviewed",
|
|
1376
|
+
}, (state) => {
|
|
1377
|
+
if (state.profile.constraints.status === "unresolved" || state.profile.revision !== request.constraint_revision) {
|
|
1378
|
+
fail("MEAL_PLANNING_PROFILE_CONFLICT", "constraint_revision is not the current confirmed meal-planning profile");
|
|
1379
|
+
}
|
|
1380
|
+
const reviewEvents = state.events.filter(({ kind }) => kind === "constraints_reviewed");
|
|
1381
|
+
if (reviewEvents.length >= MAX_MEAL_REVIEW_EVENTS
|
|
1382
|
+
|| reviewEvents.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_REVIEW_EVENTS_PER_WEEK) {
|
|
1383
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many constraint reviews");
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
id: stableLocalEntityId("mle", request.operation, request.idempotency_key),
|
|
1387
|
+
kind: "constraints_reviewed",
|
|
1388
|
+
week_start: request.week_start,
|
|
1389
|
+
actor: request.actor,
|
|
1390
|
+
constraint_revision: request.constraint_revision,
|
|
1391
|
+
occurred_at: now.toISOString(),
|
|
1392
|
+
schema_version: 1,
|
|
1393
|
+
};
|
|
1394
|
+
});
|
|
1395
|
+
return { status: result.status, ...publicDocument(result.document), event: result.entity };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export async function appendLocalMealProposal(root, input, now = new Date()) {
|
|
1399
|
+
const request = parseRequest({ ...input, operation: "append_meal_proposal" });
|
|
1400
|
+
const fingerprint = requestFingerprint({
|
|
1401
|
+
operation: request.operation,
|
|
1402
|
+
actor: request.actor,
|
|
1403
|
+
week_start: request.week_start,
|
|
1404
|
+
meal_date: request.meal_date,
|
|
1405
|
+
slot: request.slot,
|
|
1406
|
+
source: request.source,
|
|
1407
|
+
servings: request.servings,
|
|
1408
|
+
notes: request.notes,
|
|
1409
|
+
constraint_revision: request.constraint_revision,
|
|
1410
|
+
constraint_review_event_id: request.constraint_review_event_id,
|
|
1411
|
+
compatibility: request.compatibility,
|
|
1412
|
+
compatibility_caveat: request.compatibility_caveat,
|
|
1413
|
+
});
|
|
1414
|
+
const result = await appendMealPlanningMutation(root, request, now, {
|
|
1415
|
+
receiptKind: "meal_proposal",
|
|
1416
|
+
fingerprint,
|
|
1417
|
+
createdStatus: "meal_proposal_appended",
|
|
1418
|
+
}, (state, journal) => {
|
|
1419
|
+
if (state.profile.constraints.status === "unresolved" || state.profile.revision !== request.constraint_revision) {
|
|
1420
|
+
fail("MEAL_PLANNING_PROFILE_CONFLICT", "constraint_revision is not the current confirmed meal-planning profile");
|
|
1421
|
+
}
|
|
1422
|
+
const review = state.events.find(({ id }) => id === request.constraint_review_event_id);
|
|
1423
|
+
if (review?.kind !== "constraints_reviewed"
|
|
1424
|
+
|| review.week_start !== request.week_start
|
|
1425
|
+
|| review.constraint_revision !== request.constraint_revision) {
|
|
1426
|
+
fail("MEAL_CONSTRAINT_REVIEW_REQUIRED", "the current constraints must be reviewed for this week before adding a proposal");
|
|
1427
|
+
}
|
|
1428
|
+
validateLocalMealProposalSource(request.source, journal);
|
|
1429
|
+
if (state.proposals.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_PROPOSALS_PER_WEEK) {
|
|
1430
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one week");
|
|
1431
|
+
}
|
|
1432
|
+
const slotKey = mealSlotKey(request.slot);
|
|
1433
|
+
if (state.proposals.filter((proposal) =>
|
|
1434
|
+
proposal.week_start === request.week_start
|
|
1435
|
+
&& proposal.meal_date === request.meal_date
|
|
1436
|
+
&& mealSlotKey(proposal.slot) === slotKey).length >= MAX_MEAL_PROPOSALS_PER_SLOT) {
|
|
1437
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one meal slot");
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
id: stableLocalEntityId("mlp", request.operation, request.idempotency_key),
|
|
1441
|
+
week_start: request.week_start,
|
|
1442
|
+
meal_date: request.meal_date,
|
|
1443
|
+
slot: request.slot,
|
|
1444
|
+
proposed_by: request.actor,
|
|
1445
|
+
source: request.source,
|
|
1446
|
+
servings: request.servings,
|
|
1447
|
+
notes: request.notes,
|
|
1448
|
+
constraint_revision: request.constraint_revision,
|
|
1449
|
+
constraint_review_event_id: request.constraint_review_event_id,
|
|
1450
|
+
compatibility: request.compatibility,
|
|
1451
|
+
compatibility_caveat: request.compatibility_caveat,
|
|
1452
|
+
created_at: now.toISOString(),
|
|
1453
|
+
schema_version: 1,
|
|
1454
|
+
};
|
|
1455
|
+
});
|
|
1456
|
+
return { status: result.status, ...publicDocument(result.document), proposal: result.entity };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
export async function recordLocalMealPlanEvent(root, input, now = new Date()) {
|
|
1460
|
+
const request = parseRequest({ ...input, operation: "record_meal_plan_event" });
|
|
1461
|
+
const fingerprint = requestFingerprint({
|
|
1462
|
+
operation: request.operation,
|
|
1463
|
+
actor: request.actor,
|
|
1464
|
+
week_start: request.week_start,
|
|
1465
|
+
event: request.event,
|
|
1466
|
+
});
|
|
1467
|
+
const result = await appendMealPlanningMutation(root, request, now, {
|
|
1468
|
+
receiptKind: "meal_plan_event",
|
|
1469
|
+
fingerprint,
|
|
1470
|
+
createdStatus: "meal_plan_event_recorded",
|
|
1471
|
+
}, (state) => {
|
|
1472
|
+
const proposal = state.proposals.find(({ id }) => id === request.event.proposal_id);
|
|
1473
|
+
if (proposal === undefined || proposal.week_start !== request.week_start) fail("MEAL_PROPOSAL_NOT_FOUND", "proposal was not found in the requested week");
|
|
1474
|
+
if (state.events.some(({ kind, proposal_id: proposalId }) => kind === "proposal_withdrawn" && proposalId === proposal.id)) {
|
|
1475
|
+
fail("MEAL_PROPOSAL_WITHDRAWN", "proposal is already withdrawn");
|
|
1476
|
+
}
|
|
1477
|
+
const withdrawalEvents = state.events.filter(({ kind }) => kind === "proposal_withdrawn");
|
|
1478
|
+
if (withdrawalEvents.length >= MAX_MEAL_WITHDRAWAL_EVENTS
|
|
1479
|
+
|| withdrawalEvents.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK) {
|
|
1480
|
+
fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposal withdrawals");
|
|
1481
|
+
}
|
|
1482
|
+
return {
|
|
1483
|
+
id: stableLocalEntityId("mle", request.operation, request.idempotency_key),
|
|
1484
|
+
kind: "proposal_withdrawn",
|
|
1485
|
+
week_start: request.week_start,
|
|
1486
|
+
actor: request.actor,
|
|
1487
|
+
proposal_id: request.event.proposal_id,
|
|
1488
|
+
reason: request.event.reason,
|
|
1489
|
+
occurred_at: now.toISOString(),
|
|
1490
|
+
schema_version: 1,
|
|
1491
|
+
};
|
|
1492
|
+
});
|
|
1493
|
+
return { status: result.status, ...publicDocument(result.document), event: result.entity };
|
|
420
1494
|
}
|
|
421
1495
|
|
|
422
1496
|
export async function finalizeLocalHousehold(root, input, now = new Date()) {
|
|
@@ -458,7 +1532,7 @@ export async function deleteCollectingLocalHousehold(root, input, now = new Date
|
|
|
458
1532
|
const filePath = localHouseholdPath(root);
|
|
459
1533
|
const directory = path.dirname(filePath);
|
|
460
1534
|
await ensurePrivateDirectory(root, directory);
|
|
461
|
-
const lock = await
|
|
1535
|
+
const lock = await acquireLocalLock(directory, now);
|
|
462
1536
|
let result;
|
|
463
1537
|
try {
|
|
464
1538
|
const current = await readDocument(filePath);
|
|
@@ -474,7 +1548,7 @@ export async function deleteCollectingLocalHousehold(root, input, now = new Date
|
|
|
474
1548
|
result = { status: "deleted" };
|
|
475
1549
|
}
|
|
476
1550
|
} finally {
|
|
477
|
-
await
|
|
1551
|
+
await releaseLocalLock(lock);
|
|
478
1552
|
}
|
|
479
1553
|
try {
|
|
480
1554
|
await rmdir(directory);
|
|
@@ -501,9 +1575,14 @@ async function readRequest() {
|
|
|
501
1575
|
|
|
502
1576
|
export async function runRequest(root, input, now = new Date()) {
|
|
503
1577
|
const request = parseRequest(input);
|
|
504
|
-
if (request.operation === "initialize") return await initializeLocalHousehold(root, now);
|
|
1578
|
+
if (request.operation === "initialize") return await initializeLocalHousehold(root, now, request.household_name);
|
|
505
1579
|
if (request.operation === "load") return await loadLocalHousehold(root);
|
|
506
1580
|
if (request.operation === "save") return await saveLocalHousehold(root, request, now);
|
|
1581
|
+
if (request.operation === "rename_household") return await renameLocalHousehold(root, request, now);
|
|
1582
|
+
if (request.operation === "save_meal_planning_profile") return await saveLocalMealPlanningProfile(root, input, now);
|
|
1583
|
+
if (request.operation === "review_meal_constraints") return await reviewLocalMealConstraints(root, input, now);
|
|
1584
|
+
if (request.operation === "append_meal_proposal") return await appendLocalMealProposal(root, input, now);
|
|
1585
|
+
if (request.operation === "record_meal_plan_event") return await recordLocalMealPlanEvent(root, input, now);
|
|
507
1586
|
if (request.operation === "finalize") return await finalizeLocalHousehold(root, request, now);
|
|
508
1587
|
if (request.operation === "record_cloud_backup") return await recordCloudBackup(root, request, now);
|
|
509
1588
|
return await deleteCollectingLocalHousehold(root, request, now);
|