@playcademy/sandbox 0.6.1-beta.1 → 0.6.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +374 -56
  2. package/dist/server.js +374 -56
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1080,7 +1080,7 @@ var package_default;
1080
1080
  var init_package = __esm(() => {
1081
1081
  package_default = {
1082
1082
  name: "@playcademy/sandbox",
1083
- version: "0.6.1-beta.1",
1083
+ version: "0.6.1-beta.3",
1084
1084
  description: "Local development server for Playcademy game development",
1085
1085
  type: "module",
1086
1086
  exports: {
@@ -11330,7 +11330,7 @@ var init_table6 = __esm(() => {
11330
11330
  });
11331
11331
 
11332
11332
  // ../data/src/domains/timeback/table.ts
11333
- var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications;
11333
+ var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11334
11334
  var init_table7 = __esm(() => {
11335
11335
  init_drizzle_orm();
11336
11336
  init_pg_core();
@@ -11382,6 +11382,22 @@ var init_table7 = __esm(() => {
11382
11382
  uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
11383
11383
  index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
11384
11384
  ]);
11385
+ gameTimebackActivityCompletions = pgTable("game_timeback_activity_completions", {
11386
+ id: uuid("id").primaryKey().defaultRandom(),
11387
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11388
+ courseId: text("course_id").notNull(),
11389
+ studentId: text("student_id").notNull(),
11390
+ runId: uuid("run_id").notNull(),
11391
+ activityId: text("activity_id").notNull(),
11392
+ completedAt: timestamp("completed_at", { withTimezone: true }),
11393
+ reservedAt: timestamp("reserved_at", { withTimezone: true, mode: "string" }).notNull().defaultNow(),
11394
+ xpAwarded: doublePrecision("xp_awarded"),
11395
+ masteredUnitsApplied: integer("mastered_units_applied"),
11396
+ pctCompleteApp: doublePrecision("pct_complete_app"),
11397
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11398
+ }, (table3) => [
11399
+ uniqueIndex("game_timeback_activity_completions_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId, table3.activityId)
11400
+ ]);
11385
11401
  });
11386
11402
 
11387
11403
  // ../data/src/tables.index.ts
@@ -11399,6 +11415,7 @@ __export(exports_tables_index, {
11399
11415
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11400
11416
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
11401
11417
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11418
+ gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
11402
11419
  gameScoresRelations: () => gameScoresRelations,
11403
11420
  gameScores: () => gameScores,
11404
11421
  gamePlatformEnum: () => gamePlatformEnum,
@@ -24677,6 +24694,10 @@ var init_constants2 = __esm(() => {
24677
24694
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
24678
24695
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
24679
24696
  });
24697
+
24698
+ // ../cloudflare/src/playcademy/provider/assets-config.ts
24699
+ var init_assets_config = () => {};
24700
+
24680
24701
  // ../cloudflare/src/playcademy/provider/bindings.ts
24681
24702
  var init_bindings = __esm(() => {
24682
24703
  init_constants2();
@@ -24693,6 +24714,7 @@ var init_provider = __esm(() => {
24693
24714
  init_src();
24694
24715
  init_core();
24695
24716
  init_constants2();
24717
+ init_assets_config();
24696
24718
  init_bindings();
24697
24719
  init_helpers();
24698
24720
  });
@@ -27135,12 +27157,21 @@ var init_tunnel = __esm(() => {
27135
27157
  METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
27136
27158
  });
27137
27159
 
27160
+ // ../utils/src/stages.ts
27161
+ function isPreviewStage(stage) {
27162
+ return PREVIEW_STAGE_PATTERN.test(stage);
27163
+ }
27164
+ var PREVIEW_STAGE_PATTERN;
27165
+ var init_stages = __esm(() => {
27166
+ PREVIEW_STAGE_PATTERN = /^pr-\d+$/;
27167
+ });
27168
+
27138
27169
  // ../api-core/src/utils/deployment.util.ts
27139
27170
  function getDeploymentId(gameSlug, sstStage) {
27140
27171
  if (sstStage === "production") {
27141
27172
  return gameSlug;
27142
27173
  }
27143
- if (sstStage === "dev" || sstStage.startsWith("pr-")) {
27174
+ if (sstStage === "dev" || isPreviewStage(sstStage)) {
27144
27175
  return `${WORKER_NAMING.STAGING_PREFIX}${gameSlug}`;
27145
27176
  }
27146
27177
  return `${WORKER_NAMING.LOCAL_PREFIX}${sstStage}-${gameSlug}`;
@@ -27160,6 +27191,7 @@ async function generateDeploymentHash(code) {
27160
27191
  }
27161
27192
  var init_deployment_util = __esm(() => {
27162
27193
  init_src();
27194
+ init_stages();
27163
27195
  });
27164
27196
 
27165
27197
  // ../api-core/src/services/deploy.service.ts
@@ -31047,7 +31079,7 @@ var init_schemas4 = __esm(() => {
31047
31079
  subject: TimebackSubjectSchema,
31048
31080
  appName: exports_external.string().optional(),
31049
31081
  sensorUrl: exports_external.string().url().optional(),
31050
- courseId: exports_external.string().optional(),
31082
+ courseId: exports_external.string().min(1).optional(),
31051
31083
  courseName: exports_external.string().optional(),
31052
31084
  studentEmail: exports_external.string().email().optional()
31053
31085
  });
@@ -74823,7 +74855,8 @@ class ActivityRecord {
74823
74855
  courseName,
74824
74856
  subject: progressData.subject,
74825
74857
  appName: progressData.appName,
74826
- sensorUrl: progressData.sensorUrl
74858
+ sensorUrl: progressData.sensorUrl,
74859
+ eventId: progressData.completionHistoryEventId
74827
74860
  });
74828
74861
  }
74829
74862
  if (masteryProgress?.masteryRevoked) {
@@ -74848,6 +74881,7 @@ class ActivityRecord {
74848
74881
  subject: progressData.subject,
74849
74882
  appName: progressData.appName,
74850
74883
  sensorUrl: progressData.sensorUrl,
74884
+ eventId: progressData.eventId,
74851
74885
  extensions: extensions || progressData.extensions,
74852
74886
  ...progressData.runId ? { runId: progressData.runId } : {}
74853
74887
  }).catch((error88) => {
@@ -74871,6 +74905,7 @@ class ActivityRecord {
74871
74905
  }
74872
74906
  async emitCourseCompletionHistoryEvent(data) {
74873
74907
  await this.events.emitActivityEvent({
74908
+ eventId: data.eventId,
74874
74909
  studentId: data.studentId,
74875
74910
  studentEmail: data.studentEmail,
74876
74911
  gameId: data.gameId,
@@ -74986,6 +75021,7 @@ class ActivitySession {
74986
75021
  subject: sessionData.subject,
74987
75022
  appName: sessionData.appName,
74988
75023
  sensorUrl: sessionData.sensorUrl,
75024
+ eventId: sessionData.eventId,
74989
75025
  ...runId ? { runId } : {},
74990
75026
  ...extensions ? { extensions } : {}
74991
75027
  });
@@ -75252,6 +75288,9 @@ class ActivityEvents {
75252
75288
  } : {},
75253
75289
  ...eventExtensions ? { extensions: eventExtensions } : {}
75254
75290
  });
75291
+ if (data.eventId) {
75292
+ event.id = data.eventId;
75293
+ }
75255
75294
  await this.core.api.caliper.events.send(data.sensorUrl, [event]);
75256
75295
  }
75257
75296
  async emitTimeSpentEvent(data) {
@@ -75283,6 +75322,9 @@ class ActivityEvents {
75283
75322
  ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
75284
75323
  ...eventExtensions ? { extensions: eventExtensions } : {}
75285
75324
  });
75325
+ if (data.eventId) {
75326
+ event.id = data.eventId;
75327
+ }
75286
75328
  const wireEvent = data.extensions ? { ...event, generated: { ...event.generated, extensions: data.extensions } } : event;
75287
75329
  await this.core.api.caliper.events.send(data.sensorUrl, [wireEvent]);
75288
75330
  }
@@ -80315,6 +80357,7 @@ var init_timeback_removed_integration_util = __esm(() => {
80315
80357
  });
80316
80358
 
80317
80359
  // ../api-core/src/services/timeback.service.ts
80360
+ import { createHash as createHash2 } from "node:crypto";
80318
80361
  async function findGameTimebackIntegrationForUpdate(db2, condition) {
80319
80362
  const [integration] = await db2.select().from(gameTimebackIntegrations).where(condition).limit(1).for("update");
80320
80363
  return integration;
@@ -80337,6 +80380,9 @@ var init_timeback_service = __esm(async () => {
80337
80380
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
80338
80381
  static processedHeartbeatWindows = new Map;
80339
80382
  static inFlightHeartbeatWindows = new Map;
80383
+ static COMPLETION_RESERVATION_STALE_SECONDS = 5 * 60;
80384
+ static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
80385
+ static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
80340
80386
  deps;
80341
80387
  static cleanHeartbeatDedupeCache(now2 = Date.now()) {
80342
80388
  for (const [key, timestamp6] of this.processedHeartbeatWindows) {
@@ -80361,6 +80407,136 @@ var init_timeback_service = __esm(async () => {
80361
80407
  static clearInFlightHeartbeatWindow(key) {
80362
80408
  this.inFlightHeartbeatWindows.delete(key);
80363
80409
  }
80410
+ static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
80411
+ const hash2 = createHash2("sha256").update([
80412
+ "playcademy",
80413
+ "timeback",
80414
+ kind,
80415
+ key.gameId,
80416
+ key.courseId,
80417
+ key.studentId,
80418
+ key.runId,
80419
+ key.activityId,
80420
+ discriminator
80421
+ ].join("\x00")).digest("hex");
80422
+ const variant = (Number.parseInt(hash2.slice(16, 18), 16) & 63 | 128).toString(16).padStart(2, "0");
80423
+ const uuid9 = [
80424
+ hash2.slice(0, 8),
80425
+ hash2.slice(8, 12),
80426
+ `5${hash2.slice(13, 16)}`,
80427
+ `${variant}${hash2.slice(18, 20)}`,
80428
+ hash2.slice(20, 32)
80429
+ ].join("-");
80430
+ return `urn:uuid:${uuid9}`;
80431
+ }
80432
+ static async reserveCompletion(db2, key) {
80433
+ const [reserved] = await db2.insert(gameTimebackActivityCompletions).values({
80434
+ gameId: key.gameId,
80435
+ courseId: key.courseId,
80436
+ studentId: key.studentId,
80437
+ runId: key.runId,
80438
+ activityId: key.activityId
80439
+ }).onConflictDoUpdate({
80440
+ target: [
80441
+ gameTimebackActivityCompletions.gameId,
80442
+ gameTimebackActivityCompletions.courseId,
80443
+ gameTimebackActivityCompletions.studentId,
80444
+ gameTimebackActivityCompletions.runId,
80445
+ gameTimebackActivityCompletions.activityId
80446
+ ],
80447
+ set: { reservedAt: sql`now()` },
80448
+ setWhere: sql`${gameTimebackActivityCompletions.completedAt} IS NULL AND ${gameTimebackActivityCompletions.reservedAt} < now() - make_interval(secs => ${TimebackService.COMPLETION_RESERVATION_STALE_SECONDS})`
80449
+ }).returning({
80450
+ id: gameTimebackActivityCompletions.id,
80451
+ reservedAt: gameTimebackActivityCompletions.reservedAt,
80452
+ takenOver: sql`${gameTimebackActivityCompletions.reservedAt} <> ${gameTimebackActivityCompletions.createdAt}`
80453
+ });
80454
+ if (reserved) {
80455
+ return {
80456
+ status: "reserved",
80457
+ token: { id: reserved.id, reservedAt: reserved.reservedAt },
80458
+ takenOver: reserved.takenOver
80459
+ };
80460
+ }
80461
+ const existing = await db2.query.gameTimebackActivityCompletions.findFirst({
80462
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.courseId, key.courseId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId)),
80463
+ columns: {
80464
+ completedAt: true,
80465
+ xpAwarded: true,
80466
+ masteredUnitsApplied: true,
80467
+ pctCompleteApp: true
80468
+ }
80469
+ });
80470
+ if (!existing?.completedAt) {
80471
+ return { status: "in_flight" };
80472
+ }
80473
+ return {
80474
+ status: "duplicate",
80475
+ award: existing.xpAwarded !== null ? {
80476
+ xpAwarded: existing.xpAwarded,
80477
+ masteredUnitsApplied: existing.masteredUnitsApplied ?? 0,
80478
+ pctCompleteApp: existing.pctCompleteApp
80479
+ } : null
80480
+ };
80481
+ }
80482
+ static async acquireCompletionReservation(db2, key) {
80483
+ let reserveOutcome = await this.reserveCompletion(db2, key);
80484
+ for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
80485
+ await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
80486
+ reserveOutcome = await this.reserveCompletion(db2, key);
80487
+ }
80488
+ if (reserveOutcome.status === "duplicate") {
80489
+ setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
80490
+ return reserveOutcome;
80491
+ }
80492
+ if (reserveOutcome.status === "in_flight") {
80493
+ setAttribute("app.timeback.end_activity_status", "completion_reservation_in_flight");
80494
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
80495
+ }
80496
+ if (reserveOutcome.takenOver) {
80497
+ setAttribute("app.timeback.completion_reservation_takeover", true);
80498
+ addEvent("timeback.completion_reservation_takeover", {
80499
+ "app.timeback.run_id": key.runId,
80500
+ "app.timeback.activity_id": key.activityId
80501
+ });
80502
+ }
80503
+ return { status: "reserved", token: reserveOutcome.token };
80504
+ }
80505
+ static async runWithCompletionReservation(db2, key, emit, awardFromResult) {
80506
+ const acquired = await this.acquireCompletionReservation(db2, key);
80507
+ if (acquired.status === "duplicate") {
80508
+ return acquired;
80509
+ }
80510
+ const logContext = { run_id: key.runId, activity_id: key.activityId };
80511
+ let result;
80512
+ try {
80513
+ result = await emit();
80514
+ } catch (error88) {
80515
+ try {
80516
+ await this.rollbackCompletion(db2, acquired.token);
80517
+ } catch (rollbackError) {
80518
+ logTimebackError("rollback completion reservation", rollbackError, logContext);
80519
+ }
80520
+ throw error88;
80521
+ }
80522
+ try {
80523
+ await this.confirmCompletion(db2, acquired.token, awardFromResult(result));
80524
+ } catch (confirmError) {
80525
+ logTimebackError("confirm completion reservation", confirmError, logContext);
80526
+ }
80527
+ return { status: "emitted", result };
80528
+ }
80529
+ static async confirmCompletion(db2, token, award) {
80530
+ await db2.update(gameTimebackActivityCompletions).set({
80531
+ completedAt: sql`now()`,
80532
+ xpAwarded: award.xpAwarded,
80533
+ masteredUnitsApplied: award.masteredUnitsApplied,
80534
+ pctCompleteApp: award.pctCompleteApp
80535
+ }).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
80536
+ }
80537
+ static async rollbackCompletion(db2, token) {
80538
+ await db2.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
80539
+ }
80364
80540
  static addResumeIdToExtensions(extensions, resumeId) {
80365
80541
  const base = extensions ?? {};
80366
80542
  const existingPlaycademy = base.playcademy;
@@ -80373,6 +80549,24 @@ var init_timeback_service = __esm(async () => {
80373
80549
  }
80374
80550
  };
80375
80551
  }
80552
+ static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
80553
+ let requestedCourseIdStatus = "absent";
80554
+ if (requestedCourseId === resolvedCourseId) {
80555
+ requestedCourseIdStatus = "accepted";
80556
+ } else if (requestedCourseId !== undefined) {
80557
+ requestedCourseIdStatus = "ignored_mismatch";
80558
+ }
80559
+ return {
80560
+ courseId: resolvedCourseId,
80561
+ courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
80562
+ attributes: {
80563
+ "app.timeback.course_id": resolvedCourseId,
80564
+ "app.timeback.resolved_course_id": resolvedCourseId,
80565
+ "app.timeback.requested_course_id": requestedCourseId,
80566
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus
80567
+ }
80568
+ };
80569
+ }
80376
80570
  static recordRuntimeContext({
80377
80571
  operation,
80378
80572
  studentId,
@@ -81238,67 +81432,128 @@ var init_timeback_service = __esm(async () => {
81238
81432
  if (!integration) {
81239
81433
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81240
81434
  }
81435
+ const {
81436
+ courseId: runtimeCourseId,
81437
+ courseName: runtimeCourseName,
81438
+ attributes: courseAttributes
81439
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81241
81440
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81242
- const result = await client.activity.record(integration.courseId, studentId, {
81441
+ const dedupeKey = runId !== undefined ? {
81442
+ gameId,
81443
+ courseId: runtimeCourseId,
81444
+ studentId,
81445
+ runId,
81446
+ activityId: activityData.activityId
81447
+ } : undefined;
81448
+ setAttributes({
81449
+ ...courseAttributes,
81450
+ "app.timeback.grade": activityData.grade,
81451
+ "app.timeback.subject": activityData.subject,
81452
+ "app.timeback.total_questions": scoreData.totalQuestions,
81453
+ "app.timeback.correct_questions": scoreData.correctQuestions,
81454
+ "app.timeback.score_pct": scorePercentage,
81455
+ "app.timeback.xp_requested": xpEarned
81456
+ });
81457
+ const courseTotalXp = integration.totalXp;
81458
+ const activityEventContext = {
81243
81459
  gameId,
81244
- score: scorePercentage,
81245
- totalQuestions: scoreData.totalQuestions,
81246
- correctQuestions: scoreData.correctQuestions,
81247
- durationSeconds: timingData.durationSeconds,
81248
- xpEarned,
81249
- masteredUnits,
81250
- masteredUnitsAbsolute,
81251
- extensions: extensionsWithResumeId,
81252
81460
  activityId: activityData.activityId,
81253
81461
  activityName: activityData.activityName,
81254
81462
  subject: activityData.subject,
81255
81463
  appName: activityData.appName,
81256
81464
  sensorUrl: activityData.sensorUrl,
81257
- courseId: activityData.courseId,
81258
- courseName: activityData.courseName,
81465
+ courseId: runtimeCourseId,
81466
+ courseName: runtimeCourseName,
81259
81467
  studentEmail: activityData.studentEmail,
81260
- courseTotalXp: integration.totalXp,
81468
+ extensions: extensionsWithResumeId,
81261
81469
  ...runId ? { runId } : {}
81262
- });
81470
+ };
81471
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
81472
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
81473
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
81474
+ function emitRecord() {
81475
+ return client.activity.record(runtimeCourseId, studentId, {
81476
+ ...activityEventContext,
81477
+ eventId: completionEventId,
81478
+ completionHistoryEventId,
81479
+ score: scorePercentage,
81480
+ totalQuestions: scoreData.totalQuestions,
81481
+ correctQuestions: scoreData.correctQuestions,
81482
+ durationSeconds: timingData.durationSeconds,
81483
+ xpEarned,
81484
+ masteredUnits,
81485
+ masteredUnitsAbsolute,
81486
+ courseTotalXp
81487
+ });
81488
+ }
81489
+ let result;
81490
+ let blockedByDedupeGuard = false;
81491
+ let duplicateAward = null;
81492
+ if (dedupeKey) {
81493
+ const outcome = await TimebackService.runWithCompletionReservation(db2, dedupeKey, emitRecord, (emitted) => ({
81494
+ xpAwarded: emitted.xpAwarded,
81495
+ masteredUnitsApplied: emitted.masteredUnitsApplied,
81496
+ pctCompleteApp: emitted.pctCompleteApp ?? null
81497
+ }));
81498
+ if (outcome.status === "duplicate") {
81499
+ blockedByDedupeGuard = true;
81500
+ duplicateAward = outcome.award;
81501
+ } else {
81502
+ result = outcome.result;
81503
+ }
81504
+ } else {
81505
+ result = await emitRecord();
81506
+ }
81263
81507
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
81264
81508
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81265
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81266
- if (sessionEndEmitted) {
81267
- await client.activity.session(integration.courseId, studentId, {
81268
- gameId,
81269
- activeTimeSeconds: sessionEndActiveSeconds,
81270
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
81271
- activityId: activityData.activityId,
81272
- activityName: activityData.activityName,
81273
- subject: activityData.subject,
81274
- appName: activityData.appName,
81275
- sensorUrl: activityData.sensorUrl,
81276
- courseId: activityData.courseId,
81277
- courseName: activityData.courseName,
81278
- studentEmail: activityData.studentEmail,
81279
- extensions: extensionsWithResumeId,
81280
- ...runId ? { runId } : {}
81281
- });
81509
+ const sessionEndAttempted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81510
+ let sessionEndEmitted = false;
81511
+ if (sessionEndAttempted) {
81512
+ try {
81513
+ await client.activity.session(runtimeCourseId, studentId, {
81514
+ ...activityEventContext,
81515
+ eventId: sessionEndEventId,
81516
+ activeTimeSeconds: sessionEndActiveSeconds,
81517
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {}
81518
+ });
81519
+ sessionEndEmitted = true;
81520
+ } catch (sessionError) {
81521
+ logTimebackError("emit session end after completion", sessionError, {
81522
+ run_id: runId,
81523
+ activity_id: activityData.activityId
81524
+ });
81525
+ }
81282
81526
  }
81283
81527
  setAttributes({
81284
- "app.timeback.course_id": integration.courseId,
81285
- "app.timeback.grade": activityData.grade,
81286
- "app.timeback.subject": activityData.subject,
81287
- "app.timeback.total_questions": scoreData.totalQuestions,
81288
- "app.timeback.correct_questions": scoreData.correctQuestions,
81289
- "app.timeback.score_pct": scorePercentage,
81528
+ "app.timeback.session_end_attempted": sessionEndAttempted,
81290
81529
  "app.timeback.session_end_emitted": sessionEndEmitted,
81291
81530
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
81292
81531
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
81293
- "app.timeback.xp_requested": xpEarned,
81294
- "app.timeback.xp_awarded": result.xpAwarded,
81532
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
81295
81533
  "app.timeback.mastered_units_requested": masteredUnits,
81296
81534
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
81297
- "app.timeback.mastered_units_applied": result.masteredUnitsApplied
81535
+ "app.timeback.mastered_units_applied": result?.masteredUnitsApplied ?? 0,
81536
+ ...blockedByDedupeGuard ? {
81537
+ "app.timeback.xp_already_awarded": duplicateAward?.xpAwarded ?? 0,
81538
+ "app.timeback.mastered_units_already_applied": duplicateAward?.masteredUnitsApplied ?? 0
81539
+ } : {}
81298
81540
  });
81541
+ if (blockedByDedupeGuard) {
81542
+ return {
81543
+ status: "ok",
81544
+ courseId: runtimeCourseId,
81545
+ xpAwarded: duplicateAward?.xpAwarded ?? 0,
81546
+ masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
81547
+ ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
81548
+ blockedByDedupeGuard: true
81549
+ };
81550
+ }
81551
+ if (!result) {
81552
+ throw new InternalError("Completion emit did not produce a result");
81553
+ }
81299
81554
  return {
81300
81555
  status: "ok",
81301
- courseId: integration.courseId,
81556
+ courseId: runtimeCourseId,
81302
81557
  xpAwarded: result.xpAwarded,
81303
81558
  masteredUnits: result.masteredUnitsApplied,
81304
81559
  pctCompleteApp: result.pctCompleteApp,
@@ -81509,10 +81764,15 @@ var init_timeback_service = __esm(async () => {
81509
81764
  if (!integration) {
81510
81765
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81511
81766
  }
81767
+ const {
81768
+ courseId: runtimeCourseId,
81769
+ courseName: runtimeCourseName,
81770
+ attributes: courseAttributes
81771
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81512
81772
  const activeTimeSeconds = timingData.activeMs / 1000;
81513
81773
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
81514
81774
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
81515
- await client.activity.session(integration.courseId, studentId, {
81775
+ await client.activity.session(runtimeCourseId, studentId, {
81516
81776
  gameId,
81517
81777
  activeTimeSeconds,
81518
81778
  ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
@@ -81521,15 +81781,15 @@ var init_timeback_service = __esm(async () => {
81521
81781
  subject: activityData.subject,
81522
81782
  appName: activityData.appName,
81523
81783
  sensorUrl: activityData.sensorUrl,
81524
- courseId: activityData.courseId,
81525
- courseName: activityData.courseName,
81784
+ courseId: runtimeCourseId,
81785
+ courseName: runtimeCourseName,
81526
81786
  studentEmail: activityData.studentEmail,
81527
81787
  extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
81528
81788
  ...runId ? { runId } : {}
81529
81789
  });
81530
81790
  }
81531
81791
  setAttributes({
81532
- "app.timeback.course_id": integration.courseId,
81792
+ ...courseAttributes,
81533
81793
  "app.timeback.grade": activityData.grade,
81534
81794
  "app.timeback.subject": activityData.subject,
81535
81795
  "app.timeback.active_time_seconds": activeTimeSeconds,
@@ -137720,17 +137980,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
137720
137980
 
137721
137981
  // src/database/index.ts
137722
137982
  import fs3 from "node:fs";
137723
- async function createDatabaseSchema(db2) {
137983
+ function isDuplicateObjectError(error89) {
137984
+ const code = error89.code;
137985
+ if (code && DUPLICATE_OBJECT_CODES.has(code)) {
137986
+ return true;
137987
+ }
137988
+ return /already exists/i.test(errorMessage(error89));
137989
+ }
137990
+ async function reconcileMissingColumns(db2, snapshot) {
137991
+ const tables = snapshot.tables ?? {};
137992
+ for (const table9 of Object.values(tables)) {
137993
+ for (const column6 of Object.values(table9.columns ?? {})) {
137994
+ const clauses = [
137995
+ `ALTER TABLE "${table9.name}" ADD COLUMN IF NOT EXISTS "${column6.name}" ${column6.type}`
137996
+ ];
137997
+ if (column6.default !== undefined) {
137998
+ clauses.push(`DEFAULT ${column6.default}`);
137999
+ }
138000
+ if (column6.notNull && !column6.primaryKey) {
138001
+ clauses.push("NOT NULL");
138002
+ }
138003
+ try {
138004
+ await db2.execute(clauses.join(" "));
138005
+ } catch (error89) {
138006
+ console.warn(`[Sandbox] Could not add column ${table9.name}.${column6.name} to the existing database file (${errorMessage(error89)}). Delete the file to rebuild it with the current schema.`);
138007
+ }
138008
+ }
138009
+ }
138010
+ }
138011
+ async function ensureSchemaMetaTable(client) {
138012
+ await client.query(`CREATE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} (
138013
+ id integer PRIMARY KEY CHECK (id = 1),
138014
+ snapshot jsonb NOT NULL,
138015
+ updated_at timestamptz NOT NULL DEFAULT now()
138016
+ )`);
138017
+ }
138018
+ async function readStoredSnapshot(client) {
138019
+ const result = await client.query(`SELECT snapshot FROM ${SCHEMA_META_TABLE} WHERE id = 1`);
138020
+ return result.rows[0]?.snapshot ?? null;
138021
+ }
138022
+ async function writeStoredSnapshot(client, snapshot) {
138023
+ await client.query(`INSERT INTO ${SCHEMA_META_TABLE} (id, snapshot, updated_at)
138024
+ VALUES (1, $1, now())
138025
+ ON CONFLICT (id) DO UPDATE SET snapshot = $1, updated_at = now()`, [JSON.stringify(snapshot)]);
138026
+ }
138027
+ async function syncDatabaseSchema(db2, client, isExistingDb) {
137724
138028
  try {
137725
138029
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
137726
- const prevJson = generateDrizzleJson2({});
138030
+ await ensureSchemaMetaTable(client);
138031
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
138032
+ const emptyJson = generateDrizzleJson2({});
138033
+ const prevJson = stored ?? emptyJson;
137727
138034
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
137728
138035
  const statements = await generateMigration2(prevJson, curJson);
138036
+ const tolerateExisting = isExistingDb && stored === null;
137729
138037
  for (const statement of statements) {
137730
- await db2.execute(statement);
138038
+ try {
138039
+ await db2.execute(statement);
138040
+ } catch (error89) {
138041
+ if (!(tolerateExisting && isDuplicateObjectError(error89))) {
138042
+ throw error89;
138043
+ }
138044
+ }
138045
+ }
138046
+ if (tolerateExisting) {
138047
+ await reconcileMissingColumns(db2, curJson);
137731
138048
  }
138049
+ await writeStoredSnapshot(client, curJson);
137732
138050
  } catch (error89) {
137733
- console.error("[Sandbox] Schema creation failed:", error89);
138051
+ console.error("[Sandbox] Schema sync failed:", error89);
137734
138052
  throw error89;
137735
138053
  }
137736
138054
  }
@@ -137741,16 +138059,16 @@ async function setupDatabase(customPath) {
137741
138059
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
137742
138060
  await client._checkReady();
137743
138061
  const db2 = drizzle(client, { schema: exports_tables_index });
137744
- if (!dbFileExists) {
137745
- await createDatabaseSchema(db2);
137746
- }
138062
+ await syncDatabaseSchema(db2, client, dbFileExists);
137747
138063
  return db2;
137748
138064
  }
138065
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta", DUPLICATE_OBJECT_CODES;
137749
138066
  var init_database = __esm(() => {
137750
138067
  init_dist8();
137751
138068
  init_pglite();
137752
138069
  init_tables_index();
137753
138070
  init_path_manager();
138071
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
137754
138072
  });
137755
138073
 
137756
138074
  // src/lib/logging/adapter.ts
package/dist/server.js CHANGED
@@ -1079,7 +1079,7 @@ var package_default;
1079
1079
  var init_package = __esm(() => {
1080
1080
  package_default = {
1081
1081
  name: "@playcademy/sandbox",
1082
- version: "0.6.1-beta.1",
1082
+ version: "0.6.1-beta.3",
1083
1083
  description: "Local development server for Playcademy game development",
1084
1084
  type: "module",
1085
1085
  exports: {
@@ -11329,7 +11329,7 @@ var init_table6 = __esm(() => {
11329
11329
  });
11330
11330
 
11331
11331
  // ../data/src/domains/timeback/table.ts
11332
- var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications;
11332
+ var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11333
11333
  var init_table7 = __esm(() => {
11334
11334
  init_drizzle_orm();
11335
11335
  init_pg_core();
@@ -11381,6 +11381,22 @@ var init_table7 = __esm(() => {
11381
11381
  uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
11382
11382
  index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
11383
11383
  ]);
11384
+ gameTimebackActivityCompletions = pgTable("game_timeback_activity_completions", {
11385
+ id: uuid("id").primaryKey().defaultRandom(),
11386
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11387
+ courseId: text("course_id").notNull(),
11388
+ studentId: text("student_id").notNull(),
11389
+ runId: uuid("run_id").notNull(),
11390
+ activityId: text("activity_id").notNull(),
11391
+ completedAt: timestamp("completed_at", { withTimezone: true }),
11392
+ reservedAt: timestamp("reserved_at", { withTimezone: true, mode: "string" }).notNull().defaultNow(),
11393
+ xpAwarded: doublePrecision("xp_awarded"),
11394
+ masteredUnitsApplied: integer("mastered_units_applied"),
11395
+ pctCompleteApp: doublePrecision("pct_complete_app"),
11396
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11397
+ }, (table3) => [
11398
+ uniqueIndex("game_timeback_activity_completions_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId, table3.activityId)
11399
+ ]);
11384
11400
  });
11385
11401
 
11386
11402
  // ../data/src/tables.index.ts
@@ -11398,6 +11414,7 @@ __export(exports_tables_index, {
11398
11414
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11399
11415
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
11400
11416
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11417
+ gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
11401
11418
  gameScoresRelations: () => gameScoresRelations,
11402
11419
  gameScores: () => gameScores,
11403
11420
  gamePlatformEnum: () => gamePlatformEnum,
@@ -24676,6 +24693,10 @@ var init_constants2 = __esm(() => {
24676
24693
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
24677
24694
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
24678
24695
  });
24696
+
24697
+ // ../cloudflare/src/playcademy/provider/assets-config.ts
24698
+ var init_assets_config = () => {};
24699
+
24679
24700
  // ../cloudflare/src/playcademy/provider/bindings.ts
24680
24701
  var init_bindings = __esm(() => {
24681
24702
  init_constants2();
@@ -24692,6 +24713,7 @@ var init_provider = __esm(() => {
24692
24713
  init_src();
24693
24714
  init_core();
24694
24715
  init_constants2();
24716
+ init_assets_config();
24695
24717
  init_bindings();
24696
24718
  init_helpers();
24697
24719
  });
@@ -27134,12 +27156,21 @@ var init_tunnel = __esm(() => {
27134
27156
  METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
27135
27157
  });
27136
27158
 
27159
+ // ../utils/src/stages.ts
27160
+ function isPreviewStage(stage) {
27161
+ return PREVIEW_STAGE_PATTERN.test(stage);
27162
+ }
27163
+ var PREVIEW_STAGE_PATTERN;
27164
+ var init_stages = __esm(() => {
27165
+ PREVIEW_STAGE_PATTERN = /^pr-\d+$/;
27166
+ });
27167
+
27137
27168
  // ../api-core/src/utils/deployment.util.ts
27138
27169
  function getDeploymentId(gameSlug, sstStage) {
27139
27170
  if (sstStage === "production") {
27140
27171
  return gameSlug;
27141
27172
  }
27142
- if (sstStage === "dev" || sstStage.startsWith("pr-")) {
27173
+ if (sstStage === "dev" || isPreviewStage(sstStage)) {
27143
27174
  return `${WORKER_NAMING.STAGING_PREFIX}${gameSlug}`;
27144
27175
  }
27145
27176
  return `${WORKER_NAMING.LOCAL_PREFIX}${sstStage}-${gameSlug}`;
@@ -27159,6 +27190,7 @@ async function generateDeploymentHash(code) {
27159
27190
  }
27160
27191
  var init_deployment_util = __esm(() => {
27161
27192
  init_src();
27193
+ init_stages();
27162
27194
  });
27163
27195
 
27164
27196
  // ../api-core/src/services/deploy.service.ts
@@ -31046,7 +31078,7 @@ var init_schemas4 = __esm(() => {
31046
31078
  subject: TimebackSubjectSchema,
31047
31079
  appName: exports_external.string().optional(),
31048
31080
  sensorUrl: exports_external.string().url().optional(),
31049
- courseId: exports_external.string().optional(),
31081
+ courseId: exports_external.string().min(1).optional(),
31050
31082
  courseName: exports_external.string().optional(),
31051
31083
  studentEmail: exports_external.string().email().optional()
31052
31084
  });
@@ -74822,7 +74854,8 @@ class ActivityRecord {
74822
74854
  courseName,
74823
74855
  subject: progressData.subject,
74824
74856
  appName: progressData.appName,
74825
- sensorUrl: progressData.sensorUrl
74857
+ sensorUrl: progressData.sensorUrl,
74858
+ eventId: progressData.completionHistoryEventId
74826
74859
  });
74827
74860
  }
74828
74861
  if (masteryProgress?.masteryRevoked) {
@@ -74847,6 +74880,7 @@ class ActivityRecord {
74847
74880
  subject: progressData.subject,
74848
74881
  appName: progressData.appName,
74849
74882
  sensorUrl: progressData.sensorUrl,
74883
+ eventId: progressData.eventId,
74850
74884
  extensions: extensions || progressData.extensions,
74851
74885
  ...progressData.runId ? { runId: progressData.runId } : {}
74852
74886
  }).catch((error88) => {
@@ -74870,6 +74904,7 @@ class ActivityRecord {
74870
74904
  }
74871
74905
  async emitCourseCompletionHistoryEvent(data) {
74872
74906
  await this.events.emitActivityEvent({
74907
+ eventId: data.eventId,
74873
74908
  studentId: data.studentId,
74874
74909
  studentEmail: data.studentEmail,
74875
74910
  gameId: data.gameId,
@@ -74985,6 +75020,7 @@ class ActivitySession {
74985
75020
  subject: sessionData.subject,
74986
75021
  appName: sessionData.appName,
74987
75022
  sensorUrl: sessionData.sensorUrl,
75023
+ eventId: sessionData.eventId,
74988
75024
  ...runId ? { runId } : {},
74989
75025
  ...extensions ? { extensions } : {}
74990
75026
  });
@@ -75251,6 +75287,9 @@ class ActivityEvents {
75251
75287
  } : {},
75252
75288
  ...eventExtensions ? { extensions: eventExtensions } : {}
75253
75289
  });
75290
+ if (data.eventId) {
75291
+ event.id = data.eventId;
75292
+ }
75254
75293
  await this.core.api.caliper.events.send(data.sensorUrl, [event]);
75255
75294
  }
75256
75295
  async emitTimeSpentEvent(data) {
@@ -75282,6 +75321,9 @@ class ActivityEvents {
75282
75321
  ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
75283
75322
  ...eventExtensions ? { extensions: eventExtensions } : {}
75284
75323
  });
75324
+ if (data.eventId) {
75325
+ event.id = data.eventId;
75326
+ }
75285
75327
  const wireEvent = data.extensions ? { ...event, generated: { ...event.generated, extensions: data.extensions } } : event;
75286
75328
  await this.core.api.caliper.events.send(data.sensorUrl, [wireEvent]);
75287
75329
  }
@@ -80314,6 +80356,7 @@ var init_timeback_removed_integration_util = __esm(() => {
80314
80356
  });
80315
80357
 
80316
80358
  // ../api-core/src/services/timeback.service.ts
80359
+ import { createHash as createHash2 } from "node:crypto";
80317
80360
  async function findGameTimebackIntegrationForUpdate(db2, condition) {
80318
80361
  const [integration] = await db2.select().from(gameTimebackIntegrations).where(condition).limit(1).for("update");
80319
80362
  return integration;
@@ -80336,6 +80379,9 @@ var init_timeback_service = __esm(async () => {
80336
80379
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
80337
80380
  static processedHeartbeatWindows = new Map;
80338
80381
  static inFlightHeartbeatWindows = new Map;
80382
+ static COMPLETION_RESERVATION_STALE_SECONDS = 5 * 60;
80383
+ static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
80384
+ static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
80339
80385
  deps;
80340
80386
  static cleanHeartbeatDedupeCache(now2 = Date.now()) {
80341
80387
  for (const [key, timestamp6] of this.processedHeartbeatWindows) {
@@ -80360,6 +80406,136 @@ var init_timeback_service = __esm(async () => {
80360
80406
  static clearInFlightHeartbeatWindow(key) {
80361
80407
  this.inFlightHeartbeatWindows.delete(key);
80362
80408
  }
80409
+ static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
80410
+ const hash2 = createHash2("sha256").update([
80411
+ "playcademy",
80412
+ "timeback",
80413
+ kind,
80414
+ key.gameId,
80415
+ key.courseId,
80416
+ key.studentId,
80417
+ key.runId,
80418
+ key.activityId,
80419
+ discriminator
80420
+ ].join("\x00")).digest("hex");
80421
+ const variant = (Number.parseInt(hash2.slice(16, 18), 16) & 63 | 128).toString(16).padStart(2, "0");
80422
+ const uuid9 = [
80423
+ hash2.slice(0, 8),
80424
+ hash2.slice(8, 12),
80425
+ `5${hash2.slice(13, 16)}`,
80426
+ `${variant}${hash2.slice(18, 20)}`,
80427
+ hash2.slice(20, 32)
80428
+ ].join("-");
80429
+ return `urn:uuid:${uuid9}`;
80430
+ }
80431
+ static async reserveCompletion(db2, key) {
80432
+ const [reserved] = await db2.insert(gameTimebackActivityCompletions).values({
80433
+ gameId: key.gameId,
80434
+ courseId: key.courseId,
80435
+ studentId: key.studentId,
80436
+ runId: key.runId,
80437
+ activityId: key.activityId
80438
+ }).onConflictDoUpdate({
80439
+ target: [
80440
+ gameTimebackActivityCompletions.gameId,
80441
+ gameTimebackActivityCompletions.courseId,
80442
+ gameTimebackActivityCompletions.studentId,
80443
+ gameTimebackActivityCompletions.runId,
80444
+ gameTimebackActivityCompletions.activityId
80445
+ ],
80446
+ set: { reservedAt: sql`now()` },
80447
+ setWhere: sql`${gameTimebackActivityCompletions.completedAt} IS NULL AND ${gameTimebackActivityCompletions.reservedAt} < now() - make_interval(secs => ${TimebackService.COMPLETION_RESERVATION_STALE_SECONDS})`
80448
+ }).returning({
80449
+ id: gameTimebackActivityCompletions.id,
80450
+ reservedAt: gameTimebackActivityCompletions.reservedAt,
80451
+ takenOver: sql`${gameTimebackActivityCompletions.reservedAt} <> ${gameTimebackActivityCompletions.createdAt}`
80452
+ });
80453
+ if (reserved) {
80454
+ return {
80455
+ status: "reserved",
80456
+ token: { id: reserved.id, reservedAt: reserved.reservedAt },
80457
+ takenOver: reserved.takenOver
80458
+ };
80459
+ }
80460
+ const existing = await db2.query.gameTimebackActivityCompletions.findFirst({
80461
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.courseId, key.courseId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId)),
80462
+ columns: {
80463
+ completedAt: true,
80464
+ xpAwarded: true,
80465
+ masteredUnitsApplied: true,
80466
+ pctCompleteApp: true
80467
+ }
80468
+ });
80469
+ if (!existing?.completedAt) {
80470
+ return { status: "in_flight" };
80471
+ }
80472
+ return {
80473
+ status: "duplicate",
80474
+ award: existing.xpAwarded !== null ? {
80475
+ xpAwarded: existing.xpAwarded,
80476
+ masteredUnitsApplied: existing.masteredUnitsApplied ?? 0,
80477
+ pctCompleteApp: existing.pctCompleteApp
80478
+ } : null
80479
+ };
80480
+ }
80481
+ static async acquireCompletionReservation(db2, key) {
80482
+ let reserveOutcome = await this.reserveCompletion(db2, key);
80483
+ for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
80484
+ await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
80485
+ reserveOutcome = await this.reserveCompletion(db2, key);
80486
+ }
80487
+ if (reserveOutcome.status === "duplicate") {
80488
+ setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
80489
+ return reserveOutcome;
80490
+ }
80491
+ if (reserveOutcome.status === "in_flight") {
80492
+ setAttribute("app.timeback.end_activity_status", "completion_reservation_in_flight");
80493
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
80494
+ }
80495
+ if (reserveOutcome.takenOver) {
80496
+ setAttribute("app.timeback.completion_reservation_takeover", true);
80497
+ addEvent("timeback.completion_reservation_takeover", {
80498
+ "app.timeback.run_id": key.runId,
80499
+ "app.timeback.activity_id": key.activityId
80500
+ });
80501
+ }
80502
+ return { status: "reserved", token: reserveOutcome.token };
80503
+ }
80504
+ static async runWithCompletionReservation(db2, key, emit, awardFromResult) {
80505
+ const acquired = await this.acquireCompletionReservation(db2, key);
80506
+ if (acquired.status === "duplicate") {
80507
+ return acquired;
80508
+ }
80509
+ const logContext = { run_id: key.runId, activity_id: key.activityId };
80510
+ let result;
80511
+ try {
80512
+ result = await emit();
80513
+ } catch (error88) {
80514
+ try {
80515
+ await this.rollbackCompletion(db2, acquired.token);
80516
+ } catch (rollbackError) {
80517
+ logTimebackError("rollback completion reservation", rollbackError, logContext);
80518
+ }
80519
+ throw error88;
80520
+ }
80521
+ try {
80522
+ await this.confirmCompletion(db2, acquired.token, awardFromResult(result));
80523
+ } catch (confirmError) {
80524
+ logTimebackError("confirm completion reservation", confirmError, logContext);
80525
+ }
80526
+ return { status: "emitted", result };
80527
+ }
80528
+ static async confirmCompletion(db2, token, award) {
80529
+ await db2.update(gameTimebackActivityCompletions).set({
80530
+ completedAt: sql`now()`,
80531
+ xpAwarded: award.xpAwarded,
80532
+ masteredUnitsApplied: award.masteredUnitsApplied,
80533
+ pctCompleteApp: award.pctCompleteApp
80534
+ }).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
80535
+ }
80536
+ static async rollbackCompletion(db2, token) {
80537
+ await db2.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
80538
+ }
80363
80539
  static addResumeIdToExtensions(extensions, resumeId) {
80364
80540
  const base = extensions ?? {};
80365
80541
  const existingPlaycademy = base.playcademy;
@@ -80372,6 +80548,24 @@ var init_timeback_service = __esm(async () => {
80372
80548
  }
80373
80549
  };
80374
80550
  }
80551
+ static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
80552
+ let requestedCourseIdStatus = "absent";
80553
+ if (requestedCourseId === resolvedCourseId) {
80554
+ requestedCourseIdStatus = "accepted";
80555
+ } else if (requestedCourseId !== undefined) {
80556
+ requestedCourseIdStatus = "ignored_mismatch";
80557
+ }
80558
+ return {
80559
+ courseId: resolvedCourseId,
80560
+ courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
80561
+ attributes: {
80562
+ "app.timeback.course_id": resolvedCourseId,
80563
+ "app.timeback.resolved_course_id": resolvedCourseId,
80564
+ "app.timeback.requested_course_id": requestedCourseId,
80565
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus
80566
+ }
80567
+ };
80568
+ }
80375
80569
  static recordRuntimeContext({
80376
80570
  operation,
80377
80571
  studentId,
@@ -81237,67 +81431,128 @@ var init_timeback_service = __esm(async () => {
81237
81431
  if (!integration) {
81238
81432
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81239
81433
  }
81434
+ const {
81435
+ courseId: runtimeCourseId,
81436
+ courseName: runtimeCourseName,
81437
+ attributes: courseAttributes
81438
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81240
81439
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81241
- const result = await client.activity.record(integration.courseId, studentId, {
81440
+ const dedupeKey = runId !== undefined ? {
81441
+ gameId,
81442
+ courseId: runtimeCourseId,
81443
+ studentId,
81444
+ runId,
81445
+ activityId: activityData.activityId
81446
+ } : undefined;
81447
+ setAttributes({
81448
+ ...courseAttributes,
81449
+ "app.timeback.grade": activityData.grade,
81450
+ "app.timeback.subject": activityData.subject,
81451
+ "app.timeback.total_questions": scoreData.totalQuestions,
81452
+ "app.timeback.correct_questions": scoreData.correctQuestions,
81453
+ "app.timeback.score_pct": scorePercentage,
81454
+ "app.timeback.xp_requested": xpEarned
81455
+ });
81456
+ const courseTotalXp = integration.totalXp;
81457
+ const activityEventContext = {
81242
81458
  gameId,
81243
- score: scorePercentage,
81244
- totalQuestions: scoreData.totalQuestions,
81245
- correctQuestions: scoreData.correctQuestions,
81246
- durationSeconds: timingData.durationSeconds,
81247
- xpEarned,
81248
- masteredUnits,
81249
- masteredUnitsAbsolute,
81250
- extensions: extensionsWithResumeId,
81251
81459
  activityId: activityData.activityId,
81252
81460
  activityName: activityData.activityName,
81253
81461
  subject: activityData.subject,
81254
81462
  appName: activityData.appName,
81255
81463
  sensorUrl: activityData.sensorUrl,
81256
- courseId: activityData.courseId,
81257
- courseName: activityData.courseName,
81464
+ courseId: runtimeCourseId,
81465
+ courseName: runtimeCourseName,
81258
81466
  studentEmail: activityData.studentEmail,
81259
- courseTotalXp: integration.totalXp,
81467
+ extensions: extensionsWithResumeId,
81260
81468
  ...runId ? { runId } : {}
81261
- });
81469
+ };
81470
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
81471
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
81472
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
81473
+ function emitRecord() {
81474
+ return client.activity.record(runtimeCourseId, studentId, {
81475
+ ...activityEventContext,
81476
+ eventId: completionEventId,
81477
+ completionHistoryEventId,
81478
+ score: scorePercentage,
81479
+ totalQuestions: scoreData.totalQuestions,
81480
+ correctQuestions: scoreData.correctQuestions,
81481
+ durationSeconds: timingData.durationSeconds,
81482
+ xpEarned,
81483
+ masteredUnits,
81484
+ masteredUnitsAbsolute,
81485
+ courseTotalXp
81486
+ });
81487
+ }
81488
+ let result;
81489
+ let blockedByDedupeGuard = false;
81490
+ let duplicateAward = null;
81491
+ if (dedupeKey) {
81492
+ const outcome = await TimebackService.runWithCompletionReservation(db2, dedupeKey, emitRecord, (emitted) => ({
81493
+ xpAwarded: emitted.xpAwarded,
81494
+ masteredUnitsApplied: emitted.masteredUnitsApplied,
81495
+ pctCompleteApp: emitted.pctCompleteApp ?? null
81496
+ }));
81497
+ if (outcome.status === "duplicate") {
81498
+ blockedByDedupeGuard = true;
81499
+ duplicateAward = outcome.award;
81500
+ } else {
81501
+ result = outcome.result;
81502
+ }
81503
+ } else {
81504
+ result = await emitRecord();
81505
+ }
81262
81506
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
81263
81507
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81264
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81265
- if (sessionEndEmitted) {
81266
- await client.activity.session(integration.courseId, studentId, {
81267
- gameId,
81268
- activeTimeSeconds: sessionEndActiveSeconds,
81269
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
81270
- activityId: activityData.activityId,
81271
- activityName: activityData.activityName,
81272
- subject: activityData.subject,
81273
- appName: activityData.appName,
81274
- sensorUrl: activityData.sensorUrl,
81275
- courseId: activityData.courseId,
81276
- courseName: activityData.courseName,
81277
- studentEmail: activityData.studentEmail,
81278
- extensions: extensionsWithResumeId,
81279
- ...runId ? { runId } : {}
81280
- });
81508
+ const sessionEndAttempted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81509
+ let sessionEndEmitted = false;
81510
+ if (sessionEndAttempted) {
81511
+ try {
81512
+ await client.activity.session(runtimeCourseId, studentId, {
81513
+ ...activityEventContext,
81514
+ eventId: sessionEndEventId,
81515
+ activeTimeSeconds: sessionEndActiveSeconds,
81516
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {}
81517
+ });
81518
+ sessionEndEmitted = true;
81519
+ } catch (sessionError) {
81520
+ logTimebackError("emit session end after completion", sessionError, {
81521
+ run_id: runId,
81522
+ activity_id: activityData.activityId
81523
+ });
81524
+ }
81281
81525
  }
81282
81526
  setAttributes({
81283
- "app.timeback.course_id": integration.courseId,
81284
- "app.timeback.grade": activityData.grade,
81285
- "app.timeback.subject": activityData.subject,
81286
- "app.timeback.total_questions": scoreData.totalQuestions,
81287
- "app.timeback.correct_questions": scoreData.correctQuestions,
81288
- "app.timeback.score_pct": scorePercentage,
81527
+ "app.timeback.session_end_attempted": sessionEndAttempted,
81289
81528
  "app.timeback.session_end_emitted": sessionEndEmitted,
81290
81529
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
81291
81530
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
81292
- "app.timeback.xp_requested": xpEarned,
81293
- "app.timeback.xp_awarded": result.xpAwarded,
81531
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
81294
81532
  "app.timeback.mastered_units_requested": masteredUnits,
81295
81533
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
81296
- "app.timeback.mastered_units_applied": result.masteredUnitsApplied
81534
+ "app.timeback.mastered_units_applied": result?.masteredUnitsApplied ?? 0,
81535
+ ...blockedByDedupeGuard ? {
81536
+ "app.timeback.xp_already_awarded": duplicateAward?.xpAwarded ?? 0,
81537
+ "app.timeback.mastered_units_already_applied": duplicateAward?.masteredUnitsApplied ?? 0
81538
+ } : {}
81297
81539
  });
81540
+ if (blockedByDedupeGuard) {
81541
+ return {
81542
+ status: "ok",
81543
+ courseId: runtimeCourseId,
81544
+ xpAwarded: duplicateAward?.xpAwarded ?? 0,
81545
+ masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
81546
+ ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
81547
+ blockedByDedupeGuard: true
81548
+ };
81549
+ }
81550
+ if (!result) {
81551
+ throw new InternalError("Completion emit did not produce a result");
81552
+ }
81298
81553
  return {
81299
81554
  status: "ok",
81300
- courseId: integration.courseId,
81555
+ courseId: runtimeCourseId,
81301
81556
  xpAwarded: result.xpAwarded,
81302
81557
  masteredUnits: result.masteredUnitsApplied,
81303
81558
  pctCompleteApp: result.pctCompleteApp,
@@ -81508,10 +81763,15 @@ var init_timeback_service = __esm(async () => {
81508
81763
  if (!integration) {
81509
81764
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81510
81765
  }
81766
+ const {
81767
+ courseId: runtimeCourseId,
81768
+ courseName: runtimeCourseName,
81769
+ attributes: courseAttributes
81770
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81511
81771
  const activeTimeSeconds = timingData.activeMs / 1000;
81512
81772
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
81513
81773
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
81514
- await client.activity.session(integration.courseId, studentId, {
81774
+ await client.activity.session(runtimeCourseId, studentId, {
81515
81775
  gameId,
81516
81776
  activeTimeSeconds,
81517
81777
  ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
@@ -81520,15 +81780,15 @@ var init_timeback_service = __esm(async () => {
81520
81780
  subject: activityData.subject,
81521
81781
  appName: activityData.appName,
81522
81782
  sensorUrl: activityData.sensorUrl,
81523
- courseId: activityData.courseId,
81524
- courseName: activityData.courseName,
81783
+ courseId: runtimeCourseId,
81784
+ courseName: runtimeCourseName,
81525
81785
  studentEmail: activityData.studentEmail,
81526
81786
  extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
81527
81787
  ...runId ? { runId } : {}
81528
81788
  });
81529
81789
  }
81530
81790
  setAttributes({
81531
- "app.timeback.course_id": integration.courseId,
81791
+ ...courseAttributes,
81532
81792
  "app.timeback.grade": activityData.grade,
81533
81793
  "app.timeback.subject": activityData.subject,
81534
81794
  "app.timeback.active_time_seconds": activeTimeSeconds,
@@ -137719,17 +137979,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
137719
137979
 
137720
137980
  // src/database/index.ts
137721
137981
  import fs3 from "node:fs";
137722
- async function createDatabaseSchema(db2) {
137982
+ function isDuplicateObjectError(error89) {
137983
+ const code = error89.code;
137984
+ if (code && DUPLICATE_OBJECT_CODES.has(code)) {
137985
+ return true;
137986
+ }
137987
+ return /already exists/i.test(errorMessage(error89));
137988
+ }
137989
+ async function reconcileMissingColumns(db2, snapshot) {
137990
+ const tables = snapshot.tables ?? {};
137991
+ for (const table9 of Object.values(tables)) {
137992
+ for (const column6 of Object.values(table9.columns ?? {})) {
137993
+ const clauses = [
137994
+ `ALTER TABLE "${table9.name}" ADD COLUMN IF NOT EXISTS "${column6.name}" ${column6.type}`
137995
+ ];
137996
+ if (column6.default !== undefined) {
137997
+ clauses.push(`DEFAULT ${column6.default}`);
137998
+ }
137999
+ if (column6.notNull && !column6.primaryKey) {
138000
+ clauses.push("NOT NULL");
138001
+ }
138002
+ try {
138003
+ await db2.execute(clauses.join(" "));
138004
+ } catch (error89) {
138005
+ console.warn(`[Sandbox] Could not add column ${table9.name}.${column6.name} to the existing database file (${errorMessage(error89)}). Delete the file to rebuild it with the current schema.`);
138006
+ }
138007
+ }
138008
+ }
138009
+ }
138010
+ async function ensureSchemaMetaTable(client) {
138011
+ await client.query(`CREATE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} (
138012
+ id integer PRIMARY KEY CHECK (id = 1),
138013
+ snapshot jsonb NOT NULL,
138014
+ updated_at timestamptz NOT NULL DEFAULT now()
138015
+ )`);
138016
+ }
138017
+ async function readStoredSnapshot(client) {
138018
+ const result = await client.query(`SELECT snapshot FROM ${SCHEMA_META_TABLE} WHERE id = 1`);
138019
+ return result.rows[0]?.snapshot ?? null;
138020
+ }
138021
+ async function writeStoredSnapshot(client, snapshot) {
138022
+ await client.query(`INSERT INTO ${SCHEMA_META_TABLE} (id, snapshot, updated_at)
138023
+ VALUES (1, $1, now())
138024
+ ON CONFLICT (id) DO UPDATE SET snapshot = $1, updated_at = now()`, [JSON.stringify(snapshot)]);
138025
+ }
138026
+ async function syncDatabaseSchema(db2, client, isExistingDb) {
137723
138027
  try {
137724
138028
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
137725
- const prevJson = generateDrizzleJson2({});
138029
+ await ensureSchemaMetaTable(client);
138030
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
138031
+ const emptyJson = generateDrizzleJson2({});
138032
+ const prevJson = stored ?? emptyJson;
137726
138033
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
137727
138034
  const statements = await generateMigration2(prevJson, curJson);
138035
+ const tolerateExisting = isExistingDb && stored === null;
137728
138036
  for (const statement of statements) {
137729
- await db2.execute(statement);
138037
+ try {
138038
+ await db2.execute(statement);
138039
+ } catch (error89) {
138040
+ if (!(tolerateExisting && isDuplicateObjectError(error89))) {
138041
+ throw error89;
138042
+ }
138043
+ }
138044
+ }
138045
+ if (tolerateExisting) {
138046
+ await reconcileMissingColumns(db2, curJson);
137730
138047
  }
138048
+ await writeStoredSnapshot(client, curJson);
137731
138049
  } catch (error89) {
137732
- console.error("[Sandbox] Schema creation failed:", error89);
138050
+ console.error("[Sandbox] Schema sync failed:", error89);
137733
138051
  throw error89;
137734
138052
  }
137735
138053
  }
@@ -137740,16 +138058,16 @@ async function setupDatabase(customPath) {
137740
138058
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
137741
138059
  await client._checkReady();
137742
138060
  const db2 = drizzle(client, { schema: exports_tables_index });
137743
- if (!dbFileExists) {
137744
- await createDatabaseSchema(db2);
137745
- }
138061
+ await syncDatabaseSchema(db2, client, dbFileExists);
137746
138062
  return db2;
137747
138063
  }
138064
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta", DUPLICATE_OBJECT_CODES;
137748
138065
  var init_database = __esm(() => {
137749
138066
  init_dist8();
137750
138067
  init_pglite();
137751
138068
  init_tables_index();
137752
138069
  init_path_manager();
138070
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
137753
138071
  });
137754
138072
 
137755
138073
  // src/lib/logging/adapter.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.6.1-beta.1",
3
+ "version": "0.6.1-beta.3",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {