Skip to content

Commit 9862a86

Browse files
committed
Fix Tags API setup error handling
1 parent 1642cfb commit 9862a86

9 files changed

Lines changed: 310 additions & 56 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# PR_26178_ALFA_001 Branch Validation
2+
3+
## Gate Results
4+
- Current branch: PASS - PR_26178_ALFA_001-fix-tags-local-api-crash
5+
- Started from main: PASS - branch was created after main was fast-forwarded from origin/main.
6+
- Worktree scope: PASS - only Tags service, Tags service test, and required reports are changed.
7+
- start_of_day unchanged: PASS.
8+
9+
## Validation Result
10+
- PR-scoped branch validation: PASS.
11+
12+
## Commands
13+
- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` - PASS
14+
- `node --check tests/dev-runtime/TagsApiService.test.mjs` - PASS
15+
- `node ./scripts/run-node-test-files.mjs tests/dev-runtime/TagsApiService.test.mjs` - PASS
16+
17+
## Out-of-Scope Observation
18+
- `node ./scripts/run-node-test-files.mjs tests/dev-runtime/DevRuntimeBoundary.test.mjs` - FAIL on an existing router assertion for `parts[1] === "local-db"`.
19+
- No code in this branch changes that router route, so it is documented as an unrelated validation observation.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# PR_26178_ALFA_001 Manual Validation Notes
2+
3+
## Review Notes
4+
- Confirmed the Tags read path remains server/API owned and still requires the configured API database adapter.
5+
- Confirmed no Tags page, browser-owned product data, local storage product source, MEM DB, or page-local product arrays were added.
6+
- Confirmed the public failure message no longer exposes raw missing-table details such as `relation "project_tags" does not exist`.
7+
- Confirmed the operator diagnostic still carries the raw cause for developer troubleshooting.
8+
9+
## Expected Manual Behavior
10+
- With the Tags database schema applied, opening Tags and invoking the repository snapshot/list path should load seeded flat Tags from the API database.
11+
- If account, Game Hub, or Tags database setup is missing, Tags API requests should fail with a 503 setup message telling the operator to verify the API database connection and apply setup.
12+
- The service should not return fake data or silently treat missing schema as an empty Tags list.
13+
14+
## Packaging
15+
- Repo-structured delta ZIP: `tmp/PR_26178_ALFA_001-fix-tags-local-api-crash_delta.zip`
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# PR_26178_ALFA_001-fix-tags-local-api-crash Report
2+
3+
## Scope
4+
- Branch: PR_26178_ALFA_001-fix-tags-local-api-crash
5+
- Purpose: recover the Tags API service read path so `listTags` does not crash Node when `readTables` encounters missing API database setup.
6+
- Runtime boundary: Browser -> API -> Database remains the only active product-data path.
7+
8+
## Changes
9+
- Added `TagsApiSetupError` wrapping in `src/dev-runtime/toolbox-api/alfa-tool-services.mjs`.
10+
- Wrapped the shared Tags `readTables` flow used by `listTags`, snapshots, and tag write preflight reads.
11+
- Preserved raw database/schema details in `operatorDiagnostic` while returning Creator-safe actionable error text through the API error message.
12+
- Added `tests/dev-runtime/TagsApiService.test.mjs` for the Tags service read path.
13+
14+
## Non-Goals
15+
- No browser-owned product data.
16+
- No silent fallback, MEM DB, fake data, page-local arrays, or JSON source of truth.
17+
- No start_of_day changes.
18+
- No individual Tags page changes.
19+
20+
## Validation Summary
21+
- PASS: `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`
22+
- PASS: `node --check tests/dev-runtime/TagsApiService.test.mjs`
23+
- PASS: `node ./scripts/run-node-test-files.mjs tests/dev-runtime/TagsApiService.test.mjs`
24+
- INFO: `node ./scripts/run-node-test-files.mjs tests/dev-runtime/DevRuntimeBoundary.test.mjs` was attempted and exposed an existing unrelated router assertion around the legacy `local-db` route. This branch does not modify `src/dev-runtime/server/local-api-router.mjs`.
25+
26+
## Result
27+
PR-scoped validation is PASS. The Tags service now fails safely with actionable setup guidance when the API database adapter or Tags schema is missing.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# PR_26178_ALFA_001 Requirement Checklist
2+
3+
- PASS - Hard stop if not started from main: main was verified, fast-forwarded, and the PR branch was created from latest main.
4+
- PASS - Investigate `readTables` failure during `listTags`: the Tags service `readTables` path was reviewed and fixed.
5+
- PASS - Fix Local API/service-layer issue: `readTables` now wraps adapter/schema failures in `TagsApiSetupError`.
6+
- PASS - No browser-owned product data: no browser runtime or page state was added.
7+
- PASS - No silent fallback: the service throws an actionable setup error instead of returning fake or empty data.
8+
- PASS - No MEM DB, fake data, or page-local arrays: no runtime fallback store or page-local product source was added.
9+
- PASS - Tags reads use API/Database contract: the service still reads via the injected database adapter and product tables.
10+
- PASS - Missing setup fails safely: public error text is actionable, status code is 503, raw cause is operator-only diagnostic.
11+
- PASS - Targeted tests cover `listTags`/`readTables`: new Node tests cover success, missing schema, and missing adapter paths.
12+
- PASS - Required reports produced under `docs_build/dev/reports/`.
13+
- PASS - Repo-structured ZIP produced under `tmp/`.
14+
- PASS - No `start_of_day` folders modified.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# PR_26178_ALFA_001 Validation Lane
2+
3+
## Lane
4+
Focused Tags API service validation.
5+
6+
## Commands Run
7+
- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`
8+
- `node --check tests/dev-runtime/TagsApiService.test.mjs`
9+
- `node ./scripts/run-node-test-files.mjs tests/dev-runtime/TagsApiService.test.mjs`
10+
- `node ./scripts/run-node-test-files.mjs tests/dev-runtime/DevRuntimeBoundary.test.mjs`
11+
12+
## Results
13+
- PASS - Tags service syntax check.
14+
- PASS - Tags service test syntax check.
15+
- PASS - Tags service targeted Node test, 3 subtests.
16+
- INFO/OUT-OF-SCOPE FAIL - DevRuntimeBoundary exposes an existing router assertion for `parts[1] === "local-db"` in `src/dev-runtime/server/local-api-router.mjs`. This branch does not change that file or route.
17+
18+
## Playwright
19+
Playwright was not run for this service-layer crash because the targeted failure is inside `createTagsApiService().listTags()` and the new Node test validates the exact API service read path without requiring live database availability.
Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
1-
docs_build/dev/ProjectInstructions/PROJECT_INSTRUCTIONS.md
2-
docs_build/dev/ProjectInstructions/README.txt
3-
docs_build/dev/ProjectInstructions/TEAM_START_COMMANDS.md
4-
docs_build/dev/ProjectInstructions/addendums/branch_context_governance.md
5-
docs_build/dev/ProjectInstructions/addendums/branch_lock_governance.md
6-
docs_build/dev/ProjectInstructions/addendums/codex_artifact_and_reporting_standard.md
7-
docs_build/dev/ProjectInstructions/addendums/codex_project_instructions_startup.md
8-
docs_build/dev/ProjectInstructions/addendums/environment_configuration_standards.md
9-
docs_build/dev/ProjectInstructions/addendums/multi_team.md
10-
docs_build/dev/ProjectInstructions/addendums/pr_workflow.md
11-
docs_build/dev/ProjectInstructions/addendums/team_backlog_sod_eod_standard.md
12-
docs_build/dev/ProjectInstructions/addendums/tool_mvp_stacked_pr_standard.md
13-
docs_build/dev/ProjectInstructions/backlog/BACKLOG_MASTER.md
14-
docs_build/dev/ProjectInstructions/team_assignments/TEAM_ASSIGNMENTS.md
15-
docs_build/dev/ProjectInstructions/team_assignments/team_ownership.md
16-
docs_build/dev/reports/PR_26177_OWNER_012-project-instructions-cleanup-backlog-canonicalization.md
17-
docs_build/dev/reports/PR_26177_OWNER_012-project-instructions-cleanup-backlog-canonicalization_branch-validation.md
18-
docs_build/dev/reports/PR_26177_OWNER_012-project-instructions-cleanup-backlog-canonicalization_manual-validation-notes.md
19-
docs_build/dev/reports/PR_26177_OWNER_012-project-instructions-cleanup-backlog-canonicalization_requirements-checklist.md
20-
docs_build/dev/reports/PR_26177_OWNER_012-project-instructions-cleanup-backlog-canonicalization_validation-lane.md
21-
docs_build/dev/reports/codex_changed_files.txt
22-
docs_build/dev/reports/codex_review.diff
1+
A docs_build/dev/reports/PR_26178_ALFA_001-fix-tags-local-api-crash_branch-validation.md
2+
A docs_build/dev/reports/PR_26178_ALFA_001-fix-tags-local-api-crash_manual-validation-notes.md
3+
A docs_build/dev/reports/PR_26178_ALFA_001-fix-tags-local-api-crash_report.md
4+
A docs_build/dev/reports/PR_26178_ALFA_001-fix-tags-local-api-crash_requirement-checklist.md
5+
A docs_build/dev/reports/PR_26178_ALFA_001-fix-tags-local-api-crash_validation-lane.md
6+
M src/dev-runtime/toolbox-api/alfa-tool-services.mjs
7+
A tests/dev-runtime/TagsApiService.test.mjs
8+
A docs_build/dev/reports/codex_changed_files.txt
9+
A docs_build/dev/reports/codex_review.diff
-50.4 KB
Binary file not shown.

src/dev-runtime/toolbox-api/alfa-tool-services.mjs

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,19 @@ function databaseAdapter(options, action) {
265265
throw new Error(`${action} requires the configured API database adapter.`);
266266
}
267267

268+
function tagsApiSetupError(action, error) {
269+
if (error?.name === "TagsApiSetupError") {
270+
return error;
271+
}
272+
const message = `${action} failed because the Tags API database setup is unavailable. Verify the API database connection and apply the account, Game Hub, and Tags database setup before using Tags.`;
273+
const wrapped = new Error(message);
274+
wrapped.name = "TagsApiSetupError";
275+
wrapped.statusCode = typeof error?.statusCode === "number" ? error.statusCode : 503;
276+
wrapped.operatorDiagnostic = `${message} Cause: ${error instanceof Error ? error.message : String(error || "Unknown database error.")}`;
277+
wrapped.cause = error;
278+
return wrapped;
279+
}
280+
268281
function activeGameFromWorkspace(repository, overrideId = "") {
269282
if (overrideId && typeof repository?.openGame === "function") {
270283
repository.openGame(overrideId);
@@ -359,44 +372,49 @@ export function createTagsApiService(options = {}) {
359372
const seededProjectKeys = new Set();
360373

361374
async function readTables(seed = true) {
362-
const adapter = databaseAdapter(options, "Reading project tags");
363-
const project = activeProject();
364-
await ensureGameRecord(adapter, project, activeUserKey(options));
365-
let tags = await adapter.requestTable("project_tags");
366-
if (seed) {
367-
const existingSlugs = new Set(tags.map((tag) => normalizeText(tag.slug)));
368-
const missingRows = STARTER_TAGS
369-
.map(starterTagRow)
370-
.filter((row) => !existingSlugs.has(row.slug));
371-
if (missingRows.length) {
372-
await adapter.upsertProductTable("project_tags", missingRows);
373-
tags = await adapter.requestTable("project_tags");
375+
const action = "Reading project tags";
376+
try {
377+
const adapter = databaseAdapter(options, action);
378+
const project = activeProject();
379+
await ensureGameRecord(adapter, project, activeUserKey(options));
380+
let tags = await adapter.requestTable("project_tags");
381+
if (seed) {
382+
const existingSlugs = new Set(tags.map((tag) => normalizeText(tag.slug)));
383+
const missingRows = STARTER_TAGS
384+
.map(starterTagRow)
385+
.filter((row) => !existingSlugs.has(row.slug));
386+
if (missingRows.length) {
387+
await adapter.upsertProductTable("project_tags", missingRows);
388+
tags = await adapter.requestTable("project_tags");
389+
}
374390
}
375-
}
376-
let assignments = await adapter.requestTable("project_tag_assignments");
377-
if (seed && !seededProjectKeys.has(project.key)) {
378-
const projectAssignments = assignments.filter((assignment) => assignment.projectKey === project.key);
379-
if (!projectAssignments.length) {
380-
const starterTagKeys = tags
381-
.filter((tag) => ["fantasy", "platformer"].includes(normalizeText(tag.slug)))
382-
.map((tag) => tag.key);
383-
if (starterTagKeys.length) {
384-
await adapter.upsertProductTable("project_tag_assignments", starterTagKeys.map((tagKey) => ({
385-
key: adapter.createRecordKey(),
386-
projectKey: project.key,
387-
tagKey,
388-
...auditFields(SYSTEM_USER_KEY),
389-
})));
390-
assignments = await adapter.requestTable("project_tag_assignments");
391+
let assignments = await adapter.requestTable("project_tag_assignments");
392+
if (seed && !seededProjectKeys.has(project.key)) {
393+
const projectAssignments = assignments.filter((assignment) => assignment.projectKey === project.key);
394+
if (!projectAssignments.length) {
395+
const starterTagKeys = tags
396+
.filter((tag) => ["fantasy", "platformer"].includes(normalizeText(tag.slug)))
397+
.map((tag) => tag.key);
398+
if (starterTagKeys.length) {
399+
await adapter.upsertProductTable("project_tag_assignments", starterTagKeys.map((tagKey) => ({
400+
key: adapter.createRecordKey(),
401+
projectKey: project.key,
402+
tagKey,
403+
...auditFields(SYSTEM_USER_KEY),
404+
})));
405+
assignments = await adapter.requestTable("project_tag_assignments");
406+
}
391407
}
408+
seededProjectKeys.add(project.key);
392409
}
393-
seededProjectKeys.add(project.key);
410+
lastTables = {
411+
project_tag_assignments: assignments,
412+
project_tags: tags,
413+
};
414+
return lastTables;
415+
} catch (error) {
416+
throw tagsApiSetupError(action, error);
394417
}
395-
lastTables = {
396-
project_tag_assignments: assignments,
397-
project_tags: tags,
398-
};
399-
return lastTables;
400418
}
401419

402420
function activeProject() {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
createTagsApiService,
5+
gameWorkspaceGameKeyForApi,
6+
} from "../../src/dev-runtime/toolbox-api/alfa-tool-services.mjs";
7+
import { SEED_DB_KEYS, makeSeedUlid } from "../../src/dev-runtime/seed/seed-db-keys.mjs";
8+
9+
function cloneRows(rows) {
10+
return rows.map((row) => ({ ...row }));
11+
}
12+
13+
function createGameWorkspaceRepository() {
14+
return {
15+
getActiveGame() {
16+
return {
17+
id: "demo-game",
18+
name: "Demo Game",
19+
purpose: "Game",
20+
status: "Under Construction",
21+
};
22+
},
23+
};
24+
}
25+
26+
class TagsServiceTestAdapter {
27+
constructor(options = {}) {
28+
this.calls = [];
29+
this.failTable = options.failTable || "";
30+
this.failAction = options.failAction || "requestTable";
31+
this.recordIndex = 0;
32+
this.tables = {
33+
game_workspace_games: [],
34+
project_tag_assignments: [],
35+
project_tags: [],
36+
users: [],
37+
};
38+
}
39+
40+
createRecordKey() {
41+
this.recordIndex += 1;
42+
return makeSeedUlid(9800 + this.recordIndex);
43+
}
44+
45+
async requestTable(tableName, options = {}) {
46+
this.calls.push(["requestTable", tableName, options.method || "GET"]);
47+
if (this.failAction === "requestTable" && tableName === this.failTable) {
48+
throw new Error(`relation "${tableName}" does not exist`);
49+
}
50+
if (!Object.hasOwn(this.tables, tableName)) {
51+
throw new Error(`relation "${tableName}" does not exist`);
52+
}
53+
if (options.method === "DELETE") {
54+
const key = String(options.query || "").match(/key=eq\.([^&]+)/)?.[1] || "";
55+
const decodedKey = decodeURIComponent(key);
56+
const deletedRows = this.tables[tableName].filter((row) => row.key === decodedKey);
57+
this.tables[tableName] = this.tables[tableName].filter((row) => row.key !== decodedKey);
58+
return cloneRows(deletedRows);
59+
}
60+
return cloneRows(this.tables[tableName]);
61+
}
62+
63+
async upsertProductTable(tableName, rows) {
64+
this.calls.push(["upsertProductTable", tableName, rows.length]);
65+
if (this.failAction === "upsertProductTable" && tableName === this.failTable) {
66+
throw new Error(`permission denied for table "${tableName}"`);
67+
}
68+
this.#upsert(tableName, rows);
69+
}
70+
71+
async upsertTable(tableName, rows) {
72+
this.calls.push(["upsertTable", tableName, rows.length]);
73+
this.#upsert(tableName, rows);
74+
}
75+
76+
#upsert(tableName, rows) {
77+
if (!Object.hasOwn(this.tables, tableName)) {
78+
throw new Error(`relation "${tableName}" does not exist`);
79+
}
80+
rows.forEach((row) => {
81+
const index = this.tables[tableName].findIndex((candidate) => candidate.key === row.key);
82+
if (index >= 0) {
83+
this.tables[tableName][index] = { ...this.tables[tableName][index], ...row };
84+
} else {
85+
this.tables[tableName].push({ ...row });
86+
}
87+
});
88+
}
89+
}
90+
91+
function createService(adapter) {
92+
return createTagsApiService({
93+
databaseAdapter: () => adapter,
94+
gameWorkspaceRepository: createGameWorkspaceRepository(),
95+
sessionUserKey: SEED_DB_KEYS.users.user1,
96+
});
97+
}
98+
99+
test("Tags list reads and seeds through the API database adapter", async () => {
100+
const adapter = new TagsServiceTestAdapter();
101+
const service = createService(adapter);
102+
103+
const tags = await service.listTags();
104+
105+
assert.deepEqual(
106+
tags.map((tag) => tag.label).sort(),
107+
["boss-fight", "fantasy", "kids", "medium", "pixel-art", "platformer"]
108+
);
109+
assert.equal(adapter.tables.project_tags.length, 6);
110+
assert.equal(adapter.tables.game_workspace_games[0].key, gameWorkspaceGameKeyForApi("demo-game"));
111+
assert.equal(adapter.tables.game_workspace_games[0].ownerKey, SEED_DB_KEYS.users.user1);
112+
assert.equal(adapter.tables.project_tag_assignments.length, 2);
113+
assert.equal(adapter.tables.project_tag_assignments.every((row) => row.projectKey === gameWorkspaceGameKeyForApi("demo-game")), true);
114+
assert.equal(adapter.tables.project_tags.every((row) => row.createdAt && row.updatedAt && row.createdBy && row.updatedBy), true);
115+
assert.equal(adapter.calls.some(([action, table]) => action === "requestTable" && table === "project_tags"), true);
116+
assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tags"), true);
117+
assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tag_assignments"), true);
118+
});
119+
120+
test("Tags list reports an actionable setup error when readTables cannot read the schema", async () => {
121+
const adapter = new TagsServiceTestAdapter({ failTable: "project_tags" });
122+
const service = createService(adapter);
123+
124+
await assert.rejects(
125+
() => service.listTags(),
126+
(error) => {
127+
assert.equal(error.name, "TagsApiSetupError");
128+
assert.equal(error.statusCode, 503);
129+
assert.match(error.message, /Tags API database setup is unavailable/);
130+
assert.match(error.message, /apply the account, Game Hub, and Tags database setup/);
131+
assert.doesNotMatch(error.message, /relation "project_tags"/);
132+
assert.match(error.operatorDiagnostic, /relation "project_tags" does not exist/);
133+
return true;
134+
}
135+
);
136+
});
137+
138+
test("Tags list reports an actionable setup error when the API database adapter is missing", async () => {
139+
const service = createTagsApiService({
140+
gameWorkspaceRepository: createGameWorkspaceRepository(),
141+
sessionUserKey: SEED_DB_KEYS.users.user1,
142+
});
143+
144+
await assert.rejects(
145+
() => service.listTags(),
146+
(error) => {
147+
assert.equal(error.name, "TagsApiSetupError");
148+
assert.equal(error.statusCode, 503);
149+
assert.match(error.message, /Tags API database setup is unavailable/);
150+
assert.doesNotMatch(error.message, /requires the configured API database adapter/);
151+
assert.match(error.operatorDiagnostic, /requires the configured API database adapter/);
152+
return true;
153+
}
154+
);
155+
});

0 commit comments

Comments
 (0)