@playcademy/sandbox 0.6.1-beta.2 → 0.6.1-beta.4

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 +338 -48
  2. package/dist/server.js +338 -48
  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.2",
1083
+ version: "0.6.1-beta.4",
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
@@ -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;
@@ -81262,16 +81438,25 @@ var init_timeback_service = __esm(async () => {
81262
81438
  attributes: courseAttributes
81263
81439
  } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81264
81440
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81265
- const result = await client.activity.record(runtimeCourseId, 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 = {
81266
81459
  gameId,
81267
- score: scorePercentage,
81268
- totalQuestions: scoreData.totalQuestions,
81269
- correctQuestions: scoreData.correctQuestions,
81270
- durationSeconds: timingData.durationSeconds,
81271
- xpEarned,
81272
- masteredUnits,
81273
- masteredUnitsAbsolute,
81274
- extensions: extensionsWithResumeId,
81275
81460
  activityId: activityData.activityId,
81276
81461
  activityName: activityData.activityName,
81277
81462
  subject: activityData.subject,
@@ -81280,45 +81465,92 @@ var init_timeback_service = __esm(async () => {
81280
81465
  courseId: runtimeCourseId,
81281
81466
  courseName: runtimeCourseName,
81282
81467
  studentEmail: activityData.studentEmail,
81283
- courseTotalXp: integration.totalXp,
81468
+ extensions: extensionsWithResumeId,
81284
81469
  ...runId ? { runId } : {}
81285
- });
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
+ }
81286
81507
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
81287
81508
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81288
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81289
- if (sessionEndEmitted) {
81290
- await client.activity.session(runtimeCourseId, studentId, {
81291
- gameId,
81292
- activeTimeSeconds: sessionEndActiveSeconds,
81293
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
81294
- activityId: activityData.activityId,
81295
- activityName: activityData.activityName,
81296
- subject: activityData.subject,
81297
- appName: activityData.appName,
81298
- sensorUrl: activityData.sensorUrl,
81299
- courseId: runtimeCourseId,
81300
- courseName: runtimeCourseName,
81301
- studentEmail: activityData.studentEmail,
81302
- extensions: extensionsWithResumeId,
81303
- ...runId ? { runId } : {}
81304
- });
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
+ }
81305
81526
  }
81306
81527
  setAttributes({
81307
- ...courseAttributes,
81308
- "app.timeback.grade": activityData.grade,
81309
- "app.timeback.subject": activityData.subject,
81310
- "app.timeback.total_questions": scoreData.totalQuestions,
81311
- "app.timeback.correct_questions": scoreData.correctQuestions,
81312
- "app.timeback.score_pct": scorePercentage,
81528
+ "app.timeback.session_end_attempted": sessionEndAttempted,
81313
81529
  "app.timeback.session_end_emitted": sessionEndEmitted,
81314
81530
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
81315
81531
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
81316
- "app.timeback.xp_requested": xpEarned,
81317
- "app.timeback.xp_awarded": result.xpAwarded,
81532
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
81318
81533
  "app.timeback.mastered_units_requested": masteredUnits,
81319
81534
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
81320
- "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
+ } : {}
81321
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
+ }
81322
81554
  return {
81323
81555
  status: "ok",
81324
81556
  courseId: runtimeCourseId,
@@ -137748,17 +137980,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
137748
137980
 
137749
137981
  // src/database/index.ts
137750
137982
  import fs3 from "node:fs";
137751
- 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) {
137752
138028
  try {
137753
138029
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
137754
- const prevJson = generateDrizzleJson2({});
138030
+ await ensureSchemaMetaTable(client);
138031
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
138032
+ const emptyJson = generateDrizzleJson2({});
138033
+ const prevJson = stored ?? emptyJson;
137755
138034
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
137756
138035
  const statements = await generateMigration2(prevJson, curJson);
138036
+ const tolerateExisting = isExistingDb && stored === null;
137757
138037
  for (const statement of statements) {
137758
- 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);
137759
138048
  }
138049
+ await writeStoredSnapshot(client, curJson);
137760
138050
  } catch (error89) {
137761
- console.error("[Sandbox] Schema creation failed:", error89);
138051
+ console.error("[Sandbox] Schema sync failed:", error89);
137762
138052
  throw error89;
137763
138053
  }
137764
138054
  }
@@ -137769,16 +138059,16 @@ async function setupDatabase(customPath) {
137769
138059
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
137770
138060
  await client._checkReady();
137771
138061
  const db2 = drizzle(client, { schema: exports_tables_index });
137772
- if (!dbFileExists) {
137773
- await createDatabaseSchema(db2);
137774
- }
138062
+ await syncDatabaseSchema(db2, client, dbFileExists);
137775
138063
  return db2;
137776
138064
  }
138065
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta", DUPLICATE_OBJECT_CODES;
137777
138066
  var init_database = __esm(() => {
137778
138067
  init_dist8();
137779
138068
  init_pglite();
137780
138069
  init_tables_index();
137781
138070
  init_path_manager();
138071
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
137782
138072
  });
137783
138073
 
137784
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.2",
1082
+ version: "0.6.1-beta.4",
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
@@ -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;
@@ -81261,16 +81437,25 @@ var init_timeback_service = __esm(async () => {
81261
81437
  attributes: courseAttributes
81262
81438
  } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81263
81439
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81264
- const result = await client.activity.record(runtimeCourseId, 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 = {
81265
81458
  gameId,
81266
- score: scorePercentage,
81267
- totalQuestions: scoreData.totalQuestions,
81268
- correctQuestions: scoreData.correctQuestions,
81269
- durationSeconds: timingData.durationSeconds,
81270
- xpEarned,
81271
- masteredUnits,
81272
- masteredUnitsAbsolute,
81273
- extensions: extensionsWithResumeId,
81274
81459
  activityId: activityData.activityId,
81275
81460
  activityName: activityData.activityName,
81276
81461
  subject: activityData.subject,
@@ -81279,45 +81464,92 @@ var init_timeback_service = __esm(async () => {
81279
81464
  courseId: runtimeCourseId,
81280
81465
  courseName: runtimeCourseName,
81281
81466
  studentEmail: activityData.studentEmail,
81282
- courseTotalXp: integration.totalXp,
81467
+ extensions: extensionsWithResumeId,
81283
81468
  ...runId ? { runId } : {}
81284
- });
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
+ }
81285
81506
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
81286
81507
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81287
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81288
- if (sessionEndEmitted) {
81289
- await client.activity.session(runtimeCourseId, studentId, {
81290
- gameId,
81291
- activeTimeSeconds: sessionEndActiveSeconds,
81292
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
81293
- activityId: activityData.activityId,
81294
- activityName: activityData.activityName,
81295
- subject: activityData.subject,
81296
- appName: activityData.appName,
81297
- sensorUrl: activityData.sensorUrl,
81298
- courseId: runtimeCourseId,
81299
- courseName: runtimeCourseName,
81300
- studentEmail: activityData.studentEmail,
81301
- extensions: extensionsWithResumeId,
81302
- ...runId ? { runId } : {}
81303
- });
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
+ }
81304
81525
  }
81305
81526
  setAttributes({
81306
- ...courseAttributes,
81307
- "app.timeback.grade": activityData.grade,
81308
- "app.timeback.subject": activityData.subject,
81309
- "app.timeback.total_questions": scoreData.totalQuestions,
81310
- "app.timeback.correct_questions": scoreData.correctQuestions,
81311
- "app.timeback.score_pct": scorePercentage,
81527
+ "app.timeback.session_end_attempted": sessionEndAttempted,
81312
81528
  "app.timeback.session_end_emitted": sessionEndEmitted,
81313
81529
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
81314
81530
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
81315
- "app.timeback.xp_requested": xpEarned,
81316
- "app.timeback.xp_awarded": result.xpAwarded,
81531
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
81317
81532
  "app.timeback.mastered_units_requested": masteredUnits,
81318
81533
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
81319
- "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
+ } : {}
81320
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
+ }
81321
81553
  return {
81322
81554
  status: "ok",
81323
81555
  courseId: runtimeCourseId,
@@ -137747,17 +137979,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
137747
137979
 
137748
137980
  // src/database/index.ts
137749
137981
  import fs3 from "node:fs";
137750
- 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) {
137751
138027
  try {
137752
138028
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
137753
- const prevJson = generateDrizzleJson2({});
138029
+ await ensureSchemaMetaTable(client);
138030
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
138031
+ const emptyJson = generateDrizzleJson2({});
138032
+ const prevJson = stored ?? emptyJson;
137754
138033
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
137755
138034
  const statements = await generateMigration2(prevJson, curJson);
138035
+ const tolerateExisting = isExistingDb && stored === null;
137756
138036
  for (const statement of statements) {
137757
- 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);
137758
138047
  }
138048
+ await writeStoredSnapshot(client, curJson);
137759
138049
  } catch (error89) {
137760
- console.error("[Sandbox] Schema creation failed:", error89);
138050
+ console.error("[Sandbox] Schema sync failed:", error89);
137761
138051
  throw error89;
137762
138052
  }
137763
138053
  }
@@ -137768,16 +138058,16 @@ async function setupDatabase(customPath) {
137768
138058
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
137769
138059
  await client._checkReady();
137770
138060
  const db2 = drizzle(client, { schema: exports_tables_index });
137771
- if (!dbFileExists) {
137772
- await createDatabaseSchema(db2);
137773
- }
138061
+ await syncDatabaseSchema(db2, client, dbFileExists);
137774
138062
  return db2;
137775
138063
  }
138064
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta", DUPLICATE_OBJECT_CODES;
137776
138065
  var init_database = __esm(() => {
137777
138066
  init_dist8();
137778
138067
  init_pglite();
137779
138068
  init_tables_index();
137780
138069
  init_path_manager();
138070
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
137781
138071
  });
137782
138072
 
137783
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.2",
3
+ "version": "0.6.1-beta.4",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {