Save a session to start your day.
Copy, don't synchronize
Select favorites above to copy to the other database. Existing choices are kept; removals are not propagated.
@@ -269,7 +281,11 @@
Confirm selected favorites
},
});
const data = await response.json();
- if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
+ if (!response.ok) {
+ const error = new Error(`${data.error.code}: ${data.error.message}`);
+ error.code = data.error.code;
+ throw error;
+ }
return data;
}
@@ -279,6 +295,36 @@
Confirm selected favorites
if (id === "message") $(id).className = `notice ${error ? "error" : "success"}`;
}
+ function showFailure(error) {
+ const step = config?.mode === "starter"
+ ? (error.code === "TODO_LIST_SESSIONS" ? 1 : error.code === "TODO_SAVE_FAVORITE" ? 2 : undefined)
+ : undefined;
+ if (step) {
+ $("exercise").hidden = false;
+ $("exercise-title").textContent = step === 1
+ ? "Exercise 1 — Read the catalog" : "Exercise 2 — Save your first favorite";
+ $("exercise-help").textContent = step === 1
+ ? "Your database is reachable. Implement listSessions to make its catalog appear; this is an expected exercise checkpoint, not a system failure."
+ : "The catalog is working. Implement saveFavorite, then try Save favorite again. Your existing favorites have not been reset.";
+ $("exercise-guide").href = `https://github.com/documentdb/ato-2026-workshop/blob/main/docs/lab.md#${step === 1 ? "2-read-the-catalog" : "3-save-favorites-and-prove-persistence"}`;
+ } else {
+ $("exercise").hidden = true;
+ notice("error", error.message);
+ }
+ if (!state) {
+ $("catalog-empty").hidden = false;
+ $("catalog-empty").textContent = step === 1
+ ? "Next: complete Exercise 1 in exercises/starter/repository.js, save, and refresh."
+ : "The catalog could not be loaded. Resolve the error above, then refresh.";
+ $("catalog-source").textContent = step === 1
+ ? "Catalog source and snapshot details will appear after Exercise 1. The attendee guide explains the included schedule."
+ : "Catalog source details are unavailable until this database can be read.";
+ $("status").textContent = step === 1
+ ? "Database reachable. Ready for Exercise 1."
+ : "This request did not complete. Check the error above; no fallback target was used.";
+ }
+ }
+
async function perform(action) {
if (busy) return;
busy = true;
@@ -287,7 +333,7 @@
Confirm selected favorites
try {
await action();
} catch (error) {
- notice("error", error.message);
+ showFailure(error);
} finally {
busy = false;
controls();
@@ -306,7 +352,7 @@
Confirm selected favorites
function renderConfiguration() {
$("target-name").textContent = label(config.target);
- $("mode").textContent = `${config.mode === "starter" ? "TODO starter" : "Complete reference"} / Same application data layer`;
+ $("mode").textContent = `${config.mode === "starter" ? "Exercise implementation (your editable code)" : "Complete reference"} / Same application data layer`;
$("target-select").replaceChildren();
for (const { id: target } of config.targets) {
const option = node("option", label(target));
@@ -362,6 +408,7 @@
Confirm selected favorites
body: JSON.stringify({ target: config.target, sessionId: session.id }),
});
await loadState();
+ $("exercise").hidden = true;
notice("message", saved ? "Removed from the active database only." : "Favorite saved in the active database.");
});
});
@@ -422,6 +469,9 @@
Confirm selected favorites
async function refresh() {
state = undefined;
selected.clear();
+ $("exercise").hidden = true;
+ $("catalog-source").textContent = "Checking this catalog's source and snapshot date...";
+ $("favorites-empty").hidden = false;
$("sessions").replaceChildren();
$("favorites").replaceChildren();
$("copy-results").replaceChildren();
@@ -466,7 +516,7 @@
Confirm selected favorites
link.download = `${state.catalog.kind === "practice" ? "practice-" : ""}conference-favorites.ics`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
- notice("message", "Calendar export requested. Save the .ics file if your browser prompts, then import it manually; this is not a live subscription.");
+ notice("message", "Calendar export requested. Save the .ics file if prompted, then follow the Google Calendar / Apple Calendar import steps below the download button. This is a snapshot, not a live subscription.");
}));
$("copy").addEventListener("click", () => {
$("copy-route").textContent = `${label(config.target)} -> ${label(destination())}`;
diff --git a/src/server.js b/src/server.js
index 83610f3..99f9d14 100644
--- a/src/server.js
+++ b/src/server.js
@@ -88,18 +88,18 @@ export async function createApp({ config, connections, repository, logger = cons
}
const target = activeTarget;
if (request.method === "GET" && url.pathname === "/api/health") {
- await connections.db(target).command({ ping: 1 });
+ await (await connections.db(target)).command({ ping: 1 });
json(200, { ready: true, target, mode: config.mode, location: config.location });
return;
}
if (request.method === "GET" && url.pathname === "/api/state") {
- const state = await readPlanner(connections.db(target), repository);
+ const state = await readPlanner(await connections.db(target), repository);
json(200, { target, ...state });
return;
}
if (request.method === "GET" && url.pathname === "/api/calendar") {
assertTarget(url.searchParams.get("target"));
- const state = await readPlanner(connections.db(target), repository);
+ const state = await readPlanner(await connections.db(target), repository);
if (state.orphanedFavorites.length) {
throw new WorkshopError(409, "CATALOG_DRIFT", "Some favorites have no matching session. Resolve the catalog drift before exporting.");
}
@@ -120,14 +120,14 @@ export async function createApp({ config, connections, repository, logger = cons
if (!["local", "site-b", "cloud"].includes(data.nextTarget)) {
throw new WorkshopError(400, "INVALID_TARGET", "Choose one of the configured database targets.");
}
- await connections.db(data.nextTarget).command({ ping: 1 });
+ await (await connections.db(data.nextTarget)).command({ ping: 1 });
assertTarget(data.target);
activeTarget = data.nextTarget;
json(200, details());
return;
}
if (url.pathname === "/api/favorites") {
- const db = connections.db(target);
+ const db = await connections.db(target);
await requireSession(db, data.sessionId);
if (request.method === "POST") {
const result = await repository.saveFavorite(db, data.sessionId);
@@ -142,7 +142,7 @@ export async function createApp({ config, connections, repository, logger = cons
if (data.source !== target || !["local", "site-b", "cloud"].includes(data.destination) || data.source === data.destination) {
throw new WorkshopError(400, "INVALID_TRANSFER", "Copy from the active target to the other configured target.");
}
- const result = await copyFavorites(connections.db(data.source), connections.db(data.destination), data.sessionIds);
+ const result = await copyFavorites(await connections.db(data.source), await connections.db(data.destination), data.sessionIds);
if (!result.complete) {
logger.error(JSON.stringify({ event: "copy_incomplete", statuses: result.results.map((item) => item.status) }));
}
diff --git a/test/connections.test.js b/test/connections.test.js
new file mode 100644
index 0000000..b9f420f
--- /dev/null
+++ b/test/connections.test.js
@@ -0,0 +1,122 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createConnections } from "../src/connections.js";
+import { waitForDatabase } from "../scripts/database.mjs";
+
+const config = { targets: {
+ local: { uri: "mongodb://site-a.invalid", database: "test_a", developmentCertificate: true },
+ "site-b": { uri: "mongodb://site-b.invalid", database: "test_b", developmentCertificate: true },
+ cloud: { uri: "mongodb://managed.invalid", database: "test_cloud", developmentCertificate: false },
+} };
+function mockDriver() {
+ const clients = [];
+ const failure = () => Object.assign(new Error("synthetic failure"), { name: "MongoServerSelectionError" });
+ class Client {
+ constructor(uri, options) {
+ Object.assign(this, { uri, options, connects: 0, closes: 0, topology: null });
+ clients.push(this);
+ }
+ async connect() {
+ this.connects++;
+ if (Client.gate) await Client.gate;
+ // Model the pinned driver's failed-lazy-connect state. Only explicit
+ // connect may repair it; db()/commands never repair a closed topology.
+ this.topology = Client.available ? "connected" : "closed";
+ if (!Client.available) throw failure();
+ return this;
+ }
+ db(name) {
+ return { name, command: async () => {
+ if (this.topology !== "connected" || !Client.available) throw failure();
+ return { ok: 1 };
+ } };
+ }
+ async close() { this.closes++; this.topology = "closed"; }
+ }
+ Client.available = false;
+ return { Client, clients };
+}
+
+test("first failed acquisition retries explicitly on the same cached client, without replaying commands", async () => {
+ const { Client, clients } = mockDriver();
+ const connections = createConnections(config, { Client });
+ await assert.rejects(connections.db("site-b"), { name: "MongoServerSelectionError" });
+ assert.equal(clients[0].topology, "closed");
+ Client.available = true;
+ assert.deepEqual(await (await connections.db("site-b")).command({ ping: 1 }), { ok: 1 });
+ assert.equal(clients.length, 1);
+ assert.equal(clients[0].connects, 2);
+ Client.available = false;
+ await assert.rejects(clients[0].db("test_b").command({ ping: 1 }));
+ assert.equal(clients[0].connects, 2, "a failed operation isn't automatically retried");
+ Client.available = true;
+ await (await connections.db("site-b")).command({ ping: 1 });
+ assert.equal(clients[0].connects, 3);
+ await connections.close();
+});
+
+test("concurrent failed and healthy acquisitions settle, reuse one client, and keep TLS/timeouts", async () => {
+ const { Client, clients } = mockDriver();
+ const connections = createConnections(config, { Client });
+ const failed = await Promise.allSettled(Array.from({ length: 3 }, () => connections.db("site-b")));
+ assert.ok(failed.every(({ status }) => status === "rejected"));
+ Client.available = true;
+ assert.equal((await Promise.all(Array.from({ length: 3 }, () => connections.db("site-b")))).length, 3);
+ assert.equal(clients.length, 1);
+ assert.equal(clients[0].connects, 6);
+ const { options } = clients[0];
+ assert.equal(options.tls, true);
+ assert.equal(options.tlsAllowInvalidCertificates, true);
+ assert.equal(options.tlsAllowInvalidHostnames, false);
+ assert.equal(options.retryWrites, false);
+ assert.equal(options.serverSelectionTimeoutMS, 5000);
+ assert.equal(options.connectTimeoutMS, 5000);
+ assert.equal(options.timeoutMS, 10000);
+ await connections.db("cloud");
+ assert.equal(clients[1].options.tlsAllowInvalidCertificates, false);
+ await assert.rejects(connections.db("unknown"), { code: "TARGET_NOT_CONFIGURED" });
+ await connections.close();
+ assert.ok(clients.every(({ closes }) => closes === 1));
+});
+
+test("close is terminal and idempotent and waits for acquisitions without handing out a closing Db", async () => {
+ const { Client, clients } = mockDriver();
+ Client.available = true;
+ let release;
+ Client.gate = new Promise((resolve) => { release = resolve; });
+ const connections = createConnections(config, { Client });
+ const acquisition = assert.rejects(connections.db("local"), { code: "CONNECTIONS_CLOSED" });
+ const closing = connections.close();
+ assert.equal(connections.close(), closing);
+ assert.equal(clients[0].closes, 0);
+ await assert.rejects(connections.db("site-b"), { code: "CONNECTIONS_CLOSED" });
+ release();
+ await Promise.all([acquisition, closing]);
+ assert.equal(clients.length, 1);
+ assert.equal(clients[0].closes, 1);
+ await assert.rejects(connections.db("local"), { code: "CONNECTIONS_CLOSED" });
+});
+
+test("wait retries acquisition INSIDE its loop, then the original invocation succeeds", async () => {
+ let attempts = 0;
+ let clock = 0;
+ const connections = { async db(target) {
+ assert.equal(target, "site-b");
+ if (++attempts < 3) throw new Error("not ready yet");
+ return { async command(command) { assert.deepEqual(command, { ping: 1 }); } };
+ } };
+ await waitForDatabase(connections, "site-b", { timeoutMS: 6000, now: () => clock, sleep: async (ms) => { clock += ms; } });
+ assert.equal(attempts, 3);
+ assert.equal(clock, 4000);
+});
+
+test("wait deadline is bounded even when every acquisition rejects", async () => {
+ let attempts = 0;
+ let clock = 0;
+ const failure = new Error("unavailable");
+ await assert.rejects(waitForDatabase({ async db() { attempts++; throw failure; } }, "site-b", {
+ timeoutMS: 4500, now: () => clock, sleep: async (ms) => { clock += ms; },
+ }), (error) => error === failure);
+ assert.equal(attempts, 3);
+ assert.equal(clock, 4500);
+});
diff --git a/test/database.integration.js b/test/database.integration.js
index d8700a4..697c754 100644
--- a/test/database.integration.js
+++ b/test/database.integration.js
@@ -1,3 +1,6 @@
+import { inspectDatabase } from "../src/inspection.js";
+import { programCounts } from "../scripts/program-counts.mjs";
+import { programCountsPipeline } from "../examples/complete/program-counts.js";
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
@@ -24,21 +27,11 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
const config = readConfig({ ...localEnv, LOCAL_DATABASE_NAME: `verify_source_${runId}` });
const sourceConnections = createConnections(config);
const destinationConnections = createConnections(readConfig({ ...localEnv, SITE_B_DATABASE_NAME: `verify_destination_${runId}` }));
- const source = sourceConnections.db("local");
- const destination = destinationConnections.db("site-b");
- assert.notEqual(config.targets.local.uri.split("@")[1], config.targets["site-b"].uri.split("@")[1], "integration requires two distinct server endpoints");
- // Site A's credentials must not authenticate to Site B (or vice versa).
- for (const [from, to] of [["local", "site-b"], ["site-b", "local"]]) {
- const foreign = createConnections({ targets: { local: {
- ...config.targets[from],
- uri: `${config.targets[from].uri.split("@")[0]}@${config.targets[to].uri.split("@")[1]}`,
- } } });
- try {
- await assert.rejects(foreign.db("local").command({ ping: 1 }), { code: 18 });
- } finally { await foreign.close(); }
- }
+ let source;
+ let destination;
let sourceOwned = false;
let destinationOwned = false;
+ // Register ownership-aware cleanup before the first asynchronous acquisition.
t.after(async () => {
try {
if (sourceOwned) await source.dropDatabase();
@@ -50,6 +43,19 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
}
}
});
+ source = await sourceConnections.db("local");
+ destination = await destinationConnections.db("site-b");
+ assert.notEqual(config.targets.local.uri.split("@")[1], config.targets["site-b"].uri.split("@")[1], "integration requires two distinct server endpoints");
+ // Site A's credentials must not authenticate to Site B (or vice versa).
+ for (const [from, to] of [["local", "site-b"], ["site-b", "local"]]) {
+ const foreign = createConnections({ targets: { local: {
+ ...config.targets[from],
+ uri: `${config.targets[from].uri.split("@")[0]}@${config.targets[to].uri.split("@")[1]}`,
+ } } });
+ try {
+ await assert.rejects(async () => (await foreign.db("local")).command({ ping: 1 }), { code: 18 });
+ } finally { await foreign.close(); }
+ }
await source.createCollection("verification_ownership");
sourceOwned = true;
await destination.createCollection("verification_ownership");
@@ -105,6 +111,10 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
assert.match(drift.results[0].message, /details differ/);
await destination.collection("sessions").updateOne({ _id: ids[0] }, { $set: { room: originalSession.room } });
+ const beforeMigration = await inspectDatabase(source);
+ assert.equal(beforeMigration.session.schemaVersion, 1);
+ assert.equal(Object.hasOwn(beforeMigration.session, "attendanceMode"), false);
+ assert.ok(beforeMigration.favorite.savedAt);
await source.collection("sessions").createIndex({ startsAt: 1, _id: 1 }, { name: "schedule_order" });
const migrate = () => source.collection("sessions").updateMany(
{ catalogVersion: catalog.fingerprint, schemaVersion: 1, attendanceMode: { $exists: false } },
@@ -112,6 +122,12 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
);
assert.equal((await migrate()).modifiedCount, catalog.sessions.length);
assert.equal((await migrate()).modifiedCount, 0);
+ const afterMigration = await inspectDatabase(source);
+ assert.equal(afterMigration.session._id, beforeMigration.session._id);
+ assert.equal(afterMigration.session.schemaVersion, 2);
+ assert.equal(afterMigration.session.attendanceMode, "in-person");
+ assert.equal(afterMigration.favorite.savedAt, beforeMigration.favorite.savedAt);
+ assert.ok(afterMigration.indexes.some(({ name }) => name === "schedule_order"));
await seedCatalog(source, catalog);
assert.equal(await source.collection("sessions").countDocuments({ schemaVersion: 2 }), catalog.sessions.length);
const state = await readPlanner(source, repository);
@@ -120,7 +136,7 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
const app = await createApp({
config: { ...config, mode: "complete" },
- connections: { db: (target) => {
+ connections: { db: async (target) => {
if (target === "local") return source;
if (target === "site-b") return destination;
throw new WorkshopError(409, "TARGET_NOT_CONFIGURED", "No cloud access in this test.");
@@ -167,3 +183,34 @@ test("two real independent DocumentDB servers: independent data, retry-safe roun
await app.close();
}
});
+
+test("optional aggregation queries the actual official snapshot on an owned local test database", async (t) => {
+ const connections = createConnections(readConfig({
+ ...process.env, ACTIVE_TARGET: "local", WORKSHOP_SCENARIO: "attendee",
+ LOCAL_DATABASE_NAME: `verify_query_${randomUUID().replaceAll("-", "")}`,
+ }));
+ let db;
+ let owned = false;
+ t.after(async () => {
+ try { if (owned) await db.dropDatabase(); }
+ finally { await connections.close(); }
+ });
+ db = await connections.db("local");
+ await db.createCollection("verification_ownership");
+ owned = true;
+ const catalog = await readCatalog();
+ await seedCatalog(db, catalog);
+ const result = await programCounts(db, programCountsPipeline);
+ // Independent calculation: conference timezone rather than pipeline bounds.
+ const expected = new Map();
+ for (const session of catalog.sessions) {
+ const day = new Intl.DateTimeFormat("en-CA", { timeZone: catalog.timeZone }).format(new Date(session.startsAt));
+ if (day === "2026-10-19") expected.set(session.track, (expected.get(session.track) || 0) + 1);
+ }
+ assert.deepEqual(result.rows, [...expected].map(([_id, entries]) => ({ _id, entries }))
+ .sort((a, b) => b.entries - a.entries || a._id.localeCompare(b._id)));
+ assert.equal(result.rows.length, 17);
+ assert.equal(result.rows.reduce((sum, row) => sum + row.entries, 0), 74);
+ assert.equal(await db.collection("sessions").countDocuments(), 216);
+ assert.equal(await db.collection("favorites").countDocuments(), 0);
+});
diff --git a/test/experience.test.js b/test/experience.test.js
new file mode 100644
index 0000000..3bd63ea
--- /dev/null
+++ b/test/experience.test.js
@@ -0,0 +1,117 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import vm from "node:vm";
+import { readCatalog } from "../src/catalog.js";
+import { inspectDatabase } from "../src/inspection.js";
+import { programCounts, validatePipeline } from "../scripts/program-counts.mjs";
+import { programCountsPipeline } from "../examples/complete/program-counts.js";
+
+async function browser() {
+ const html = await readFile(new URL("../src/public/index.html", import.meta.url), "utf8");
+ const script = [...html.matchAll(/