@playcademy/vite-plugin 1.1.2 → 1.1.3-beta.2

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 (2) hide show
  1. package/dist/index.js +335 -48
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -23746,7 +23746,7 @@ import path from "node:path";
23746
23746
  // package.json
23747
23747
  var package_default = {
23748
23748
  name: "@playcademy/vite-plugin",
23749
- version: "1.1.2",
23749
+ version: "1.1.3-beta.2",
23750
23750
  type: "module",
23751
23751
  exports: {
23752
23752
  ".": {
@@ -24245,6 +24245,7 @@ import { Buffer as Buffer2 } from "node:buffer";
24245
24245
  import { gzipSync } from "node:zlib";
24246
24246
  import { stdout } from "process";
24247
24247
  import { createHash } from "node:crypto";
24248
+ import { createHash as createHash2 } from "node:crypto";
24248
24249
  import crypto3 from "node:crypto";
24249
24250
  import * as s3 from "fs";
24250
24251
  import * as o3 from "path";
@@ -25345,7 +25346,7 @@ var package_default2;
25345
25346
  var init_package = __esm(() => {
25346
25347
  package_default2 = {
25347
25348
  name: "@playcademy/sandbox",
25348
- version: "0.6.0",
25349
+ version: "0.6.1-beta.3",
25349
25350
  description: "Local development server for Playcademy game development",
25350
25351
  type: "module",
25351
25352
  exports: {
@@ -35620,6 +35621,7 @@ var gameTimebackIntegrationStatusEnum;
35620
35621
  var gameTimebackIntegrations;
35621
35622
  var gameTimebackAssessmentTests;
35622
35623
  var gameTimebackMetricDiscrepancyVerifications;
35624
+ var gameTimebackActivityCompletions;
35623
35625
  var init_table7 = __esm(() => {
35624
35626
  init_drizzle_orm();
35625
35627
  init_pg_core();
@@ -35671,6 +35673,22 @@ var init_table7 = __esm(() => {
35671
35673
  uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
35672
35674
  index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
35673
35675
  ]);
35676
+ gameTimebackActivityCompletions = pgTable("game_timeback_activity_completions", {
35677
+ id: uuid("id").primaryKey().defaultRandom(),
35678
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
35679
+ courseId: text("course_id").notNull(),
35680
+ studentId: text("student_id").notNull(),
35681
+ runId: uuid("run_id").notNull(),
35682
+ activityId: text("activity_id").notNull(),
35683
+ completedAt: timestamp("completed_at", { withTimezone: true }),
35684
+ reservedAt: timestamp("reserved_at", { withTimezone: true, mode: "string" }).notNull().defaultNow(),
35685
+ xpAwarded: doublePrecision("xp_awarded"),
35686
+ masteredUnitsApplied: integer("mastered_units_applied"),
35687
+ pctCompleteApp: doublePrecision("pct_complete_app"),
35688
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
35689
+ }, (table3) => [
35690
+ uniqueIndex("game_timeback_activity_completions_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId, table3.activityId)
35691
+ ]);
35674
35692
  });
35675
35693
  var exports_tables_index = {};
35676
35694
  __export(exports_tables_index, {
@@ -35686,6 +35704,7 @@ __export(exports_tables_index, {
35686
35704
  gameTimebackIntegrations: () => gameTimebackIntegrations,
35687
35705
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
35688
35706
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
35707
+ gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
35689
35708
  gameScoresRelations: () => gameScoresRelations,
35690
35709
  gameScores: () => gameScores,
35691
35710
  gamePlatformEnum: () => gamePlatformEnum,
@@ -48688,6 +48707,7 @@ var init_constants2 = __esm(() => {
48688
48707
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
48689
48708
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
48690
48709
  });
48710
+ var init_assets_config = () => {};
48691
48711
  var init_bindings = __esm(() => {
48692
48712
  init_constants2();
48693
48713
  });
@@ -48699,6 +48719,7 @@ var init_provider = __esm(() => {
48699
48719
  init_src();
48700
48720
  init_core();
48701
48721
  init_constants2();
48722
+ init_assets_config();
48702
48723
  init_bindings();
48703
48724
  init_helpers();
48704
48725
  });
@@ -51121,11 +51142,18 @@ var METRICS_BASE;
51121
51142
  var init_tunnel = __esm(() => {
51122
51143
  METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
51123
51144
  });
51145
+ function isPreviewStage(stage) {
51146
+ return PREVIEW_STAGE_PATTERN.test(stage);
51147
+ }
51148
+ var PREVIEW_STAGE_PATTERN;
51149
+ var init_stages = __esm(() => {
51150
+ PREVIEW_STAGE_PATTERN = /^pr-\d+$/;
51151
+ });
51124
51152
  function getDeploymentId(gameSlug, sstStage) {
51125
51153
  if (sstStage === "production") {
51126
51154
  return gameSlug;
51127
51155
  }
51128
- if (sstStage === "dev" || sstStage.startsWith("pr-")) {
51156
+ if (sstStage === "dev" || isPreviewStage(sstStage)) {
51129
51157
  return `${WORKER_NAMING.STAGING_PREFIX}${gameSlug}`;
51130
51158
  }
51131
51159
  return `${WORKER_NAMING.LOCAL_PREFIX}${sstStage}-${gameSlug}`;
@@ -51145,6 +51173,7 @@ async function generateDeploymentHash(code) {
51145
51173
  }
51146
51174
  var init_deployment_util = __esm(() => {
51147
51175
  init_src();
51176
+ init_stages();
51148
51177
  });
51149
51178
 
51150
51179
  class DeployService {
@@ -100025,7 +100054,8 @@ class ActivityRecord {
100025
100054
  courseName,
100026
100055
  subject: progressData.subject,
100027
100056
  appName: progressData.appName,
100028
- sensorUrl: progressData.sensorUrl
100057
+ sensorUrl: progressData.sensorUrl,
100058
+ eventId: progressData.completionHistoryEventId
100029
100059
  });
100030
100060
  }
100031
100061
  if (masteryProgress?.masteryRevoked) {
@@ -100050,6 +100080,7 @@ class ActivityRecord {
100050
100080
  subject: progressData.subject,
100051
100081
  appName: progressData.appName,
100052
100082
  sensorUrl: progressData.sensorUrl,
100083
+ eventId: progressData.eventId,
100053
100084
  extensions: extensions || progressData.extensions,
100054
100085
  ...progressData.runId ? { runId: progressData.runId } : {}
100055
100086
  }).catch((error88) => {
@@ -100073,6 +100104,7 @@ class ActivityRecord {
100073
100104
  }
100074
100105
  async emitCourseCompletionHistoryEvent(data) {
100075
100106
  await this.events.emitActivityEvent({
100107
+ eventId: data.eventId,
100076
100108
  studentId: data.studentId,
100077
100109
  studentEmail: data.studentEmail,
100078
100110
  gameId: data.gameId,
@@ -100188,6 +100220,7 @@ class ActivitySession {
100188
100220
  subject: sessionData.subject,
100189
100221
  appName: sessionData.appName,
100190
100222
  sensorUrl: sessionData.sensorUrl,
100223
+ eventId: sessionData.eventId,
100191
100224
  ...runId ? { runId } : {},
100192
100225
  ...extensions ? { extensions } : {}
100193
100226
  });
@@ -100454,6 +100487,9 @@ class ActivityEvents {
100454
100487
  } : {},
100455
100488
  ...eventExtensions ? { extensions: eventExtensions } : {}
100456
100489
  });
100490
+ if (data.eventId) {
100491
+ event.id = data.eventId;
100492
+ }
100457
100493
  await this.core.api.caliper.events.send(data.sensorUrl, [event]);
100458
100494
  }
100459
100495
  async emitTimeSpentEvent(data) {
@@ -100485,6 +100521,9 @@ class ActivityEvents {
100485
100521
  ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
100486
100522
  ...eventExtensions ? { extensions: eventExtensions } : {}
100487
100523
  });
100524
+ if (data.eventId) {
100525
+ event.id = data.eventId;
100526
+ }
100488
100527
  const wireEvent = data.extensions ? { ...event, generated: { ...event.generated, extensions: data.extensions } } : event;
100489
100528
  await this.core.api.caliper.events.send(data.sensorUrl, [wireEvent]);
100490
100529
  }
@@ -105542,6 +105581,9 @@ var init_timeback_service = __esm(async () => {
105542
105581
  static HEARTBEAT_DEDUPE_TTL_MS = 300000;
105543
105582
  static processedHeartbeatWindows = new Map;
105544
105583
  static inFlightHeartbeatWindows = new Map;
105584
+ static COMPLETION_RESERVATION_STALE_SECONDS = 300;
105585
+ static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
105586
+ static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
105545
105587
  deps;
105546
105588
  static cleanHeartbeatDedupeCache(now2 = Date.now()) {
105547
105589
  for (const [key, timestamp6] of this.processedHeartbeatWindows) {
@@ -105566,6 +105608,136 @@ var init_timeback_service = __esm(async () => {
105566
105608
  static clearInFlightHeartbeatWindow(key) {
105567
105609
  this.inFlightHeartbeatWindows.delete(key);
105568
105610
  }
105611
+ static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
105612
+ const hash2 = createHash2("sha256").update([
105613
+ "playcademy",
105614
+ "timeback",
105615
+ kind,
105616
+ key.gameId,
105617
+ key.courseId,
105618
+ key.studentId,
105619
+ key.runId,
105620
+ key.activityId,
105621
+ discriminator
105622
+ ].join("\x00")).digest("hex");
105623
+ const variant = (Number.parseInt(hash2.slice(16, 18), 16) & 63 | 128).toString(16).padStart(2, "0");
105624
+ const uuid9 = [
105625
+ hash2.slice(0, 8),
105626
+ hash2.slice(8, 12),
105627
+ `5${hash2.slice(13, 16)}`,
105628
+ `${variant}${hash2.slice(18, 20)}`,
105629
+ hash2.slice(20, 32)
105630
+ ].join("-");
105631
+ return `urn:uuid:${uuid9}`;
105632
+ }
105633
+ static async reserveCompletion(db2, key) {
105634
+ const [reserved] = await db2.insert(gameTimebackActivityCompletions).values({
105635
+ gameId: key.gameId,
105636
+ courseId: key.courseId,
105637
+ studentId: key.studentId,
105638
+ runId: key.runId,
105639
+ activityId: key.activityId
105640
+ }).onConflictDoUpdate({
105641
+ target: [
105642
+ gameTimebackActivityCompletions.gameId,
105643
+ gameTimebackActivityCompletions.courseId,
105644
+ gameTimebackActivityCompletions.studentId,
105645
+ gameTimebackActivityCompletions.runId,
105646
+ gameTimebackActivityCompletions.activityId
105647
+ ],
105648
+ set: { reservedAt: sql`now()` },
105649
+ setWhere: sql`${gameTimebackActivityCompletions.completedAt} IS NULL AND ${gameTimebackActivityCompletions.reservedAt} < now() - make_interval(secs => ${TimebackService2.COMPLETION_RESERVATION_STALE_SECONDS})`
105650
+ }).returning({
105651
+ id: gameTimebackActivityCompletions.id,
105652
+ reservedAt: gameTimebackActivityCompletions.reservedAt,
105653
+ takenOver: sql`${gameTimebackActivityCompletions.reservedAt} <> ${gameTimebackActivityCompletions.createdAt}`
105654
+ });
105655
+ if (reserved) {
105656
+ return {
105657
+ status: "reserved",
105658
+ token: { id: reserved.id, reservedAt: reserved.reservedAt },
105659
+ takenOver: reserved.takenOver
105660
+ };
105661
+ }
105662
+ const existing = await db2.query.gameTimebackActivityCompletions.findFirst({
105663
+ 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)),
105664
+ columns: {
105665
+ completedAt: true,
105666
+ xpAwarded: true,
105667
+ masteredUnitsApplied: true,
105668
+ pctCompleteApp: true
105669
+ }
105670
+ });
105671
+ if (!existing?.completedAt) {
105672
+ return { status: "in_flight" };
105673
+ }
105674
+ return {
105675
+ status: "duplicate",
105676
+ award: existing.xpAwarded !== null ? {
105677
+ xpAwarded: existing.xpAwarded,
105678
+ masteredUnitsApplied: existing.masteredUnitsApplied ?? 0,
105679
+ pctCompleteApp: existing.pctCompleteApp
105680
+ } : null
105681
+ };
105682
+ }
105683
+ static async acquireCompletionReservation(db2, key) {
105684
+ let reserveOutcome = await this.reserveCompletion(db2, key);
105685
+ for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
105686
+ await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
105687
+ reserveOutcome = await this.reserveCompletion(db2, key);
105688
+ }
105689
+ if (reserveOutcome.status === "duplicate") {
105690
+ setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
105691
+ return reserveOutcome;
105692
+ }
105693
+ if (reserveOutcome.status === "in_flight") {
105694
+ setAttribute("app.timeback.end_activity_status", "completion_reservation_in_flight");
105695
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
105696
+ }
105697
+ if (reserveOutcome.takenOver) {
105698
+ setAttribute("app.timeback.completion_reservation_takeover", true);
105699
+ addEvent("timeback.completion_reservation_takeover", {
105700
+ "app.timeback.run_id": key.runId,
105701
+ "app.timeback.activity_id": key.activityId
105702
+ });
105703
+ }
105704
+ return { status: "reserved", token: reserveOutcome.token };
105705
+ }
105706
+ static async runWithCompletionReservation(db2, key, emit, awardFromResult) {
105707
+ const acquired = await this.acquireCompletionReservation(db2, key);
105708
+ if (acquired.status === "duplicate") {
105709
+ return acquired;
105710
+ }
105711
+ const logContext = { run_id: key.runId, activity_id: key.activityId };
105712
+ let result;
105713
+ try {
105714
+ result = await emit();
105715
+ } catch (error88) {
105716
+ try {
105717
+ await this.rollbackCompletion(db2, acquired.token);
105718
+ } catch (rollbackError) {
105719
+ logTimebackError("rollback completion reservation", rollbackError, logContext);
105720
+ }
105721
+ throw error88;
105722
+ }
105723
+ try {
105724
+ await this.confirmCompletion(db2, acquired.token, awardFromResult(result));
105725
+ } catch (confirmError) {
105726
+ logTimebackError("confirm completion reservation", confirmError, logContext);
105727
+ }
105728
+ return { status: "emitted", result };
105729
+ }
105730
+ static async confirmCompletion(db2, token, award) {
105731
+ await db2.update(gameTimebackActivityCompletions).set({
105732
+ completedAt: sql`now()`,
105733
+ xpAwarded: award.xpAwarded,
105734
+ masteredUnitsApplied: award.masteredUnitsApplied,
105735
+ pctCompleteApp: award.pctCompleteApp
105736
+ }).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
105737
+ }
105738
+ static async rollbackCompletion(db2, token) {
105739
+ await db2.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
105740
+ }
105569
105741
  static addResumeIdToExtensions(extensions, resumeId) {
105570
105742
  const base = extensions ?? {};
105571
105743
  const existingPlaycademy = base.playcademy;
@@ -106467,16 +106639,25 @@ var init_timeback_service = __esm(async () => {
106467
106639
  attributes: courseAttributes
106468
106640
  } = TimebackService2.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
106469
106641
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
106470
- const result = await client.activity.record(runtimeCourseId, studentId, {
106642
+ const dedupeKey = runId !== undefined ? {
106643
+ gameId,
106644
+ courseId: runtimeCourseId,
106645
+ studentId,
106646
+ runId,
106647
+ activityId: activityData.activityId
106648
+ } : undefined;
106649
+ setAttributes({
106650
+ ...courseAttributes,
106651
+ "app.timeback.grade": activityData.grade,
106652
+ "app.timeback.subject": activityData.subject,
106653
+ "app.timeback.total_questions": scoreData.totalQuestions,
106654
+ "app.timeback.correct_questions": scoreData.correctQuestions,
106655
+ "app.timeback.score_pct": scorePercentage,
106656
+ "app.timeback.xp_requested": xpEarned
106657
+ });
106658
+ const courseTotalXp = integration.totalXp;
106659
+ const activityEventContext = {
106471
106660
  gameId,
106472
- score: scorePercentage,
106473
- totalQuestions: scoreData.totalQuestions,
106474
- correctQuestions: scoreData.correctQuestions,
106475
- durationSeconds: timingData.durationSeconds,
106476
- xpEarned,
106477
- masteredUnits,
106478
- masteredUnitsAbsolute,
106479
- extensions: extensionsWithResumeId,
106480
106661
  activityId: activityData.activityId,
106481
106662
  activityName: activityData.activityName,
106482
106663
  subject: activityData.subject,
@@ -106485,45 +106666,92 @@ var init_timeback_service = __esm(async () => {
106485
106666
  courseId: runtimeCourseId,
106486
106667
  courseName: runtimeCourseName,
106487
106668
  studentEmail: activityData.studentEmail,
106488
- courseTotalXp: integration.totalXp,
106669
+ extensions: extensionsWithResumeId,
106489
106670
  ...runId ? { runId } : {}
106490
- });
106671
+ };
106672
+ const completionEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
106673
+ const sessionEndEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
106674
+ const completionHistoryEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
106675
+ function emitRecord() {
106676
+ return client.activity.record(runtimeCourseId, studentId, {
106677
+ ...activityEventContext,
106678
+ eventId: completionEventId,
106679
+ completionHistoryEventId,
106680
+ score: scorePercentage,
106681
+ totalQuestions: scoreData.totalQuestions,
106682
+ correctQuestions: scoreData.correctQuestions,
106683
+ durationSeconds: timingData.durationSeconds,
106684
+ xpEarned,
106685
+ masteredUnits,
106686
+ masteredUnitsAbsolute,
106687
+ courseTotalXp
106688
+ });
106689
+ }
106690
+ let result;
106691
+ let blockedByDedupeGuard = false;
106692
+ let duplicateAward = null;
106693
+ if (dedupeKey) {
106694
+ const outcome = await TimebackService2.runWithCompletionReservation(db2, dedupeKey, emitRecord, (emitted) => ({
106695
+ xpAwarded: emitted.xpAwarded,
106696
+ masteredUnitsApplied: emitted.masteredUnitsApplied,
106697
+ pctCompleteApp: emitted.pctCompleteApp ?? null
106698
+ }));
106699
+ if (outcome.status === "duplicate") {
106700
+ blockedByDedupeGuard = true;
106701
+ duplicateAward = outcome.award;
106702
+ } else {
106703
+ result = outcome.result;
106704
+ }
106705
+ } else {
106706
+ result = await emitRecord();
106707
+ }
106491
106708
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
106492
106709
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
106493
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
106494
- if (sessionEndEmitted) {
106495
- await client.activity.session(runtimeCourseId, studentId, {
106496
- gameId,
106497
- activeTimeSeconds: sessionEndActiveSeconds,
106498
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
106499
- activityId: activityData.activityId,
106500
- activityName: activityData.activityName,
106501
- subject: activityData.subject,
106502
- appName: activityData.appName,
106503
- sensorUrl: activityData.sensorUrl,
106504
- courseId: runtimeCourseId,
106505
- courseName: runtimeCourseName,
106506
- studentEmail: activityData.studentEmail,
106507
- extensions: extensionsWithResumeId,
106508
- ...runId ? { runId } : {}
106509
- });
106710
+ const sessionEndAttempted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
106711
+ let sessionEndEmitted = false;
106712
+ if (sessionEndAttempted) {
106713
+ try {
106714
+ await client.activity.session(runtimeCourseId, studentId, {
106715
+ ...activityEventContext,
106716
+ eventId: sessionEndEventId,
106717
+ activeTimeSeconds: sessionEndActiveSeconds,
106718
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {}
106719
+ });
106720
+ sessionEndEmitted = true;
106721
+ } catch (sessionError) {
106722
+ logTimebackError("emit session end after completion", sessionError, {
106723
+ run_id: runId,
106724
+ activity_id: activityData.activityId
106725
+ });
106726
+ }
106510
106727
  }
106511
106728
  setAttributes({
106512
- ...courseAttributes,
106513
- "app.timeback.grade": activityData.grade,
106514
- "app.timeback.subject": activityData.subject,
106515
- "app.timeback.total_questions": scoreData.totalQuestions,
106516
- "app.timeback.correct_questions": scoreData.correctQuestions,
106517
- "app.timeback.score_pct": scorePercentage,
106729
+ "app.timeback.session_end_attempted": sessionEndAttempted,
106518
106730
  "app.timeback.session_end_emitted": sessionEndEmitted,
106519
106731
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
106520
106732
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
106521
- "app.timeback.xp_requested": xpEarned,
106522
- "app.timeback.xp_awarded": result.xpAwarded,
106733
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
106523
106734
  "app.timeback.mastered_units_requested": masteredUnits,
106524
106735
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
106525
- "app.timeback.mastered_units_applied": result.masteredUnitsApplied
106736
+ "app.timeback.mastered_units_applied": result?.masteredUnitsApplied ?? 0,
106737
+ ...blockedByDedupeGuard ? {
106738
+ "app.timeback.xp_already_awarded": duplicateAward?.xpAwarded ?? 0,
106739
+ "app.timeback.mastered_units_already_applied": duplicateAward?.masteredUnitsApplied ?? 0
106740
+ } : {}
106526
106741
  });
106742
+ if (blockedByDedupeGuard) {
106743
+ return {
106744
+ status: "ok",
106745
+ courseId: runtimeCourseId,
106746
+ xpAwarded: duplicateAward?.xpAwarded ?? 0,
106747
+ masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
106748
+ ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
106749
+ blockedByDedupeGuard: true
106750
+ };
106751
+ }
106752
+ if (!result) {
106753
+ throw new InternalError("Completion emit did not produce a result");
106754
+ }
106527
106755
  return {
106528
106756
  status: "ok",
106529
106757
  courseId: runtimeCourseId,
@@ -165558,17 +165786,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
165558
165786
  init_sqliteSchema();
165559
165787
  init_sqliteSerializer();
165560
165788
  });
165561
- async function createDatabaseSchema(db2) {
165789
+ function isDuplicateObjectError(error89) {
165790
+ const code = error89.code;
165791
+ if (code && DUPLICATE_OBJECT_CODES.has(code)) {
165792
+ return true;
165793
+ }
165794
+ return /already exists/i.test(errorMessage(error89));
165795
+ }
165796
+ async function reconcileMissingColumns(db2, snapshot) {
165797
+ const tables = snapshot.tables ?? {};
165798
+ for (const table9 of Object.values(tables)) {
165799
+ for (const column6 of Object.values(table9.columns ?? {})) {
165800
+ const clauses = [
165801
+ `ALTER TABLE "${table9.name}" ADD COLUMN IF NOT EXISTS "${column6.name}" ${column6.type}`
165802
+ ];
165803
+ if (column6.default !== undefined) {
165804
+ clauses.push(`DEFAULT ${column6.default}`);
165805
+ }
165806
+ if (column6.notNull && !column6.primaryKey) {
165807
+ clauses.push("NOT NULL");
165808
+ }
165809
+ try {
165810
+ await db2.execute(clauses.join(" "));
165811
+ } catch (error89) {
165812
+ 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.`);
165813
+ }
165814
+ }
165815
+ }
165816
+ }
165817
+ async function ensureSchemaMetaTable(client) {
165818
+ await client.query(`CREATE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} (
165819
+ id integer PRIMARY KEY CHECK (id = 1),
165820
+ snapshot jsonb NOT NULL,
165821
+ updated_at timestamptz NOT NULL DEFAULT now()
165822
+ )`);
165823
+ }
165824
+ async function readStoredSnapshot(client) {
165825
+ const result = await client.query(`SELECT snapshot FROM ${SCHEMA_META_TABLE} WHERE id = 1`);
165826
+ return result.rows[0]?.snapshot ?? null;
165827
+ }
165828
+ async function writeStoredSnapshot(client, snapshot) {
165829
+ await client.query(`INSERT INTO ${SCHEMA_META_TABLE} (id, snapshot, updated_at)
165830
+ VALUES (1, $1, now())
165831
+ ON CONFLICT (id) DO UPDATE SET snapshot = $1, updated_at = now()`, [JSON.stringify(snapshot)]);
165832
+ }
165833
+ async function syncDatabaseSchema(db2, client, isExistingDb) {
165562
165834
  try {
165563
165835
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
165564
- const prevJson = generateDrizzleJson2({});
165836
+ await ensureSchemaMetaTable(client);
165837
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
165838
+ const emptyJson = generateDrizzleJson2({});
165839
+ const prevJson = stored ?? emptyJson;
165565
165840
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
165566
165841
  const statements = await generateMigration2(prevJson, curJson);
165842
+ const tolerateExisting = isExistingDb && stored === null;
165567
165843
  for (const statement of statements) {
165568
- await db2.execute(statement);
165844
+ try {
165845
+ await db2.execute(statement);
165846
+ } catch (error89) {
165847
+ if (!(tolerateExisting && isDuplicateObjectError(error89))) {
165848
+ throw error89;
165849
+ }
165850
+ }
165851
+ }
165852
+ if (tolerateExisting) {
165853
+ await reconcileMissingColumns(db2, curJson);
165569
165854
  }
165855
+ await writeStoredSnapshot(client, curJson);
165570
165856
  } catch (error89) {
165571
- console.error("[Sandbox] Schema creation failed:", error89);
165857
+ console.error("[Sandbox] Schema sync failed:", error89);
165572
165858
  throw error89;
165573
165859
  }
165574
165860
  }
@@ -165579,16 +165865,17 @@ async function setupDatabase(customPath) {
165579
165865
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
165580
165866
  await client._checkReady();
165581
165867
  const db2 = drizzle(client, { schema: exports_tables_index });
165582
- if (!dbFileExists) {
165583
- await createDatabaseSchema(db2);
165584
- }
165868
+ await syncDatabaseSchema(db2, client, dbFileExists);
165585
165869
  return db2;
165586
165870
  }
165871
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta";
165872
+ var DUPLICATE_OBJECT_CODES;
165587
165873
  var init_database = __esm(() => {
165588
165874
  init_dist8();
165589
165875
  init_pglite();
165590
165876
  init_tables_index();
165591
165877
  init_path_manager();
165878
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
165592
165879
  });
165593
165880
  function setLogger(logger3) {
165594
165881
  customLogger = logger3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "1.1.2",
3
+ "version": "1.1.3-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,14 +19,14 @@
19
19
  "dependencies": {
20
20
  "archiver": "^7.0.1",
21
21
  "picocolors": "^1.1.1",
22
- "playcademy": "0.27.0"
22
+ "playcademy": "0.27.1-beta.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@electric-sql/pglite": "^0.3.16",
26
26
  "@inquirer/prompts": "^7.8.6",
27
27
  "@playcademy/constants": "0.0.1",
28
- "@playcademy/sandbox": "0.6.0",
29
- "@playcademy/sdk": "0.15.0",
28
+ "@playcademy/sandbox": "0.6.1-beta.3",
29
+ "@playcademy/sdk": "0.15.1-beta.1",
30
30
  "@playcademy/types": "0.0.1",
31
31
  "@playcademy/utils": "0.0.1",
32
32
  "@types/archiver": "^6.0.3",