@playcademy/vite-plugin 1.1.3-beta.1 → 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 +333 -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.3-beta.1",
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,
@@ -51123,11 +51142,18 @@ var METRICS_BASE;
51123
51142
  var init_tunnel = __esm(() => {
51124
51143
  METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
51125
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
+ });
51126
51152
  function getDeploymentId(gameSlug, sstStage) {
51127
51153
  if (sstStage === "production") {
51128
51154
  return gameSlug;
51129
51155
  }
51130
- if (sstStage === "dev" || sstStage.startsWith("pr-")) {
51156
+ if (sstStage === "dev" || isPreviewStage(sstStage)) {
51131
51157
  return `${WORKER_NAMING.STAGING_PREFIX}${gameSlug}`;
51132
51158
  }
51133
51159
  return `${WORKER_NAMING.LOCAL_PREFIX}${sstStage}-${gameSlug}`;
@@ -51147,6 +51173,7 @@ async function generateDeploymentHash(code) {
51147
51173
  }
51148
51174
  var init_deployment_util = __esm(() => {
51149
51175
  init_src();
51176
+ init_stages();
51150
51177
  });
51151
51178
 
51152
51179
  class DeployService {
@@ -100027,7 +100054,8 @@ class ActivityRecord {
100027
100054
  courseName,
100028
100055
  subject: progressData.subject,
100029
100056
  appName: progressData.appName,
100030
- sensorUrl: progressData.sensorUrl
100057
+ sensorUrl: progressData.sensorUrl,
100058
+ eventId: progressData.completionHistoryEventId
100031
100059
  });
100032
100060
  }
100033
100061
  if (masteryProgress?.masteryRevoked) {
@@ -100052,6 +100080,7 @@ class ActivityRecord {
100052
100080
  subject: progressData.subject,
100053
100081
  appName: progressData.appName,
100054
100082
  sensorUrl: progressData.sensorUrl,
100083
+ eventId: progressData.eventId,
100055
100084
  extensions: extensions || progressData.extensions,
100056
100085
  ...progressData.runId ? { runId: progressData.runId } : {}
100057
100086
  }).catch((error88) => {
@@ -100075,6 +100104,7 @@ class ActivityRecord {
100075
100104
  }
100076
100105
  async emitCourseCompletionHistoryEvent(data) {
100077
100106
  await this.events.emitActivityEvent({
100107
+ eventId: data.eventId,
100078
100108
  studentId: data.studentId,
100079
100109
  studentEmail: data.studentEmail,
100080
100110
  gameId: data.gameId,
@@ -100190,6 +100220,7 @@ class ActivitySession {
100190
100220
  subject: sessionData.subject,
100191
100221
  appName: sessionData.appName,
100192
100222
  sensorUrl: sessionData.sensorUrl,
100223
+ eventId: sessionData.eventId,
100193
100224
  ...runId ? { runId } : {},
100194
100225
  ...extensions ? { extensions } : {}
100195
100226
  });
@@ -100456,6 +100487,9 @@ class ActivityEvents {
100456
100487
  } : {},
100457
100488
  ...eventExtensions ? { extensions: eventExtensions } : {}
100458
100489
  });
100490
+ if (data.eventId) {
100491
+ event.id = data.eventId;
100492
+ }
100459
100493
  await this.core.api.caliper.events.send(data.sensorUrl, [event]);
100460
100494
  }
100461
100495
  async emitTimeSpentEvent(data) {
@@ -100487,6 +100521,9 @@ class ActivityEvents {
100487
100521
  ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
100488
100522
  ...eventExtensions ? { extensions: eventExtensions } : {}
100489
100523
  });
100524
+ if (data.eventId) {
100525
+ event.id = data.eventId;
100526
+ }
100490
100527
  const wireEvent = data.extensions ? { ...event, generated: { ...event.generated, extensions: data.extensions } } : event;
100491
100528
  await this.core.api.caliper.events.send(data.sensorUrl, [wireEvent]);
100492
100529
  }
@@ -105544,6 +105581,9 @@ var init_timeback_service = __esm(async () => {
105544
105581
  static HEARTBEAT_DEDUPE_TTL_MS = 300000;
105545
105582
  static processedHeartbeatWindows = new Map;
105546
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;
105547
105587
  deps;
105548
105588
  static cleanHeartbeatDedupeCache(now2 = Date.now()) {
105549
105589
  for (const [key, timestamp6] of this.processedHeartbeatWindows) {
@@ -105568,6 +105608,136 @@ var init_timeback_service = __esm(async () => {
105568
105608
  static clearInFlightHeartbeatWindow(key) {
105569
105609
  this.inFlightHeartbeatWindows.delete(key);
105570
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
+ }
105571
105741
  static addResumeIdToExtensions(extensions, resumeId) {
105572
105742
  const base = extensions ?? {};
105573
105743
  const existingPlaycademy = base.playcademy;
@@ -106469,16 +106639,25 @@ var init_timeback_service = __esm(async () => {
106469
106639
  attributes: courseAttributes
106470
106640
  } = TimebackService2.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
106471
106641
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
106472
- 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 = {
106473
106660
  gameId,
106474
- score: scorePercentage,
106475
- totalQuestions: scoreData.totalQuestions,
106476
- correctQuestions: scoreData.correctQuestions,
106477
- durationSeconds: timingData.durationSeconds,
106478
- xpEarned,
106479
- masteredUnits,
106480
- masteredUnitsAbsolute,
106481
- extensions: extensionsWithResumeId,
106482
106661
  activityId: activityData.activityId,
106483
106662
  activityName: activityData.activityName,
106484
106663
  subject: activityData.subject,
@@ -106487,45 +106666,92 @@ var init_timeback_service = __esm(async () => {
106487
106666
  courseId: runtimeCourseId,
106488
106667
  courseName: runtimeCourseName,
106489
106668
  studentEmail: activityData.studentEmail,
106490
- courseTotalXp: integration.totalXp,
106669
+ extensions: extensionsWithResumeId,
106491
106670
  ...runId ? { runId } : {}
106492
- });
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
+ }
106493
106708
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
106494
106709
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
106495
- const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
106496
- if (sessionEndEmitted) {
106497
- await client.activity.session(runtimeCourseId, studentId, {
106498
- gameId,
106499
- activeTimeSeconds: sessionEndActiveSeconds,
106500
- ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
106501
- activityId: activityData.activityId,
106502
- activityName: activityData.activityName,
106503
- subject: activityData.subject,
106504
- appName: activityData.appName,
106505
- sensorUrl: activityData.sensorUrl,
106506
- courseId: runtimeCourseId,
106507
- courseName: runtimeCourseName,
106508
- studentEmail: activityData.studentEmail,
106509
- extensions: extensionsWithResumeId,
106510
- ...runId ? { runId } : {}
106511
- });
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
+ }
106512
106727
  }
106513
106728
  setAttributes({
106514
- ...courseAttributes,
106515
- "app.timeback.grade": activityData.grade,
106516
- "app.timeback.subject": activityData.subject,
106517
- "app.timeback.total_questions": scoreData.totalQuestions,
106518
- "app.timeback.correct_questions": scoreData.correctQuestions,
106519
- "app.timeback.score_pct": scorePercentage,
106729
+ "app.timeback.session_end_attempted": sessionEndAttempted,
106520
106730
  "app.timeback.session_end_emitted": sessionEndEmitted,
106521
106731
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
106522
106732
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
106523
- "app.timeback.xp_requested": xpEarned,
106524
- "app.timeback.xp_awarded": result.xpAwarded,
106733
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
106525
106734
  "app.timeback.mastered_units_requested": masteredUnits,
106526
106735
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
106527
- "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
+ } : {}
106528
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
+ }
106529
106755
  return {
106530
106756
  status: "ok",
106531
106757
  courseId: runtimeCourseId,
@@ -165560,17 +165786,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
165560
165786
  init_sqliteSchema();
165561
165787
  init_sqliteSerializer();
165562
165788
  });
165563
- 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) {
165564
165834
  try {
165565
165835
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
165566
- const prevJson = generateDrizzleJson2({});
165836
+ await ensureSchemaMetaTable(client);
165837
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
165838
+ const emptyJson = generateDrizzleJson2({});
165839
+ const prevJson = stored ?? emptyJson;
165567
165840
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
165568
165841
  const statements = await generateMigration2(prevJson, curJson);
165842
+ const tolerateExisting = isExistingDb && stored === null;
165569
165843
  for (const statement of statements) {
165570
- 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);
165571
165854
  }
165855
+ await writeStoredSnapshot(client, curJson);
165572
165856
  } catch (error89) {
165573
- console.error("[Sandbox] Schema creation failed:", error89);
165857
+ console.error("[Sandbox] Schema sync failed:", error89);
165574
165858
  throw error89;
165575
165859
  }
165576
165860
  }
@@ -165581,16 +165865,17 @@ async function setupDatabase(customPath) {
165581
165865
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
165582
165866
  await client._checkReady();
165583
165867
  const db2 = drizzle(client, { schema: exports_tables_index });
165584
- if (!dbFileExists) {
165585
- await createDatabaseSchema(db2);
165586
- }
165868
+ await syncDatabaseSchema(db2, client, dbFileExists);
165587
165869
  return db2;
165588
165870
  }
165871
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta";
165872
+ var DUPLICATE_OBJECT_CODES;
165589
165873
  var init_database = __esm(() => {
165590
165874
  init_dist8();
165591
165875
  init_pglite();
165592
165876
  init_tables_index();
165593
165877
  init_path_manager();
165878
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
165594
165879
  });
165595
165880
  function setLogger(logger3) {
165596
165881
  customLogger = logger3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "1.1.3-beta.1",
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.1-beta.1"
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",