@playcademy/vite-plugin 1.1.3-beta.1 → 1.1.3-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +345 -51
  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.3",
23750
23750
  type: "module",
23751
23751
  exports: {
23752
23752
  ".": {
@@ -23796,9 +23796,8 @@ function formatNumberWithCommas(numStr) {
23796
23796
  parts2[0] = parts2[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
23797
23797
  return parts2.join(".");
23798
23798
  }
23799
- async function resolvePackageVersion(projectRoot, packageName) {
23799
+ async function readPackageVersion(pkgJsonPath) {
23800
23800
  try {
23801
- const pkgJsonPath = path.resolve(projectRoot, "node_modules", packageName, "package.json");
23802
23801
  const raw = await fs2.readFile(pkgJsonPath, "utf8");
23803
23802
  const pkg = JSON.parse(raw);
23804
23803
  return pkg.version;
@@ -23806,6 +23805,12 @@ async function resolvePackageVersion(projectRoot, packageName) {
23806
23805
  return;
23807
23806
  }
23808
23807
  }
23808
+ async function resolvePackageVersion(projectRoot, packageName) {
23809
+ return readPackageVersion(path.resolve(projectRoot, "node_modules", packageName, "package.json"));
23810
+ }
23811
+ async function resolveGameVersion(projectRoot) {
23812
+ return readPackageVersion(path.resolve(projectRoot, "package.json"));
23813
+ }
23809
23814
  async function resolveCliVersion() {
23810
23815
  const bin = path.join(homedir(), ".playcademy", "bin", "playcademy");
23811
23816
  return new Promise((resolve) => {
@@ -23835,11 +23840,15 @@ async function resolveVersions(projectRoot) {
23835
23840
  return versions;
23836
23841
  }
23837
23842
  async function generatePlaycademyManifest(config, outDir, buildOutputs) {
23838
- const versions = await resolveVersions(config.root);
23843
+ const [versions, gameVersion] = await Promise.all([
23844
+ resolveVersions(config.root),
23845
+ resolveGameVersion(config.root)
23846
+ ]);
23839
23847
  const manifestData = {
23840
23848
  version: "2",
23841
23849
  platform: "web",
23842
23850
  createdAt: new Date().toISOString(),
23851
+ ...gameVersion ? { gameVersion } : {},
23843
23852
  versions
23844
23853
  };
23845
23854
  const manifestPath = path.resolve(outDir, "playcademy.manifest.json");
@@ -24245,6 +24254,7 @@ import { Buffer as Buffer2 } from "node:buffer";
24245
24254
  import { gzipSync } from "node:zlib";
24246
24255
  import { stdout } from "process";
24247
24256
  import { createHash } from "node:crypto";
24257
+ import { createHash as createHash2 } from "node:crypto";
24248
24258
  import crypto3 from "node:crypto";
24249
24259
  import * as s3 from "fs";
24250
24260
  import * as o3 from "path";
@@ -25345,7 +25355,7 @@ var package_default2;
25345
25355
  var init_package = __esm(() => {
25346
25356
  package_default2 = {
25347
25357
  name: "@playcademy/sandbox",
25348
- version: "0.6.0",
25358
+ version: "0.6.1-beta.4",
25349
25359
  description: "Local development server for Playcademy game development",
25350
25360
  type: "module",
25351
25361
  exports: {
@@ -35620,6 +35630,7 @@ var gameTimebackIntegrationStatusEnum;
35620
35630
  var gameTimebackIntegrations;
35621
35631
  var gameTimebackAssessmentTests;
35622
35632
  var gameTimebackMetricDiscrepancyVerifications;
35633
+ var gameTimebackActivityCompletions;
35623
35634
  var init_table7 = __esm(() => {
35624
35635
  init_drizzle_orm();
35625
35636
  init_pg_core();
@@ -35671,6 +35682,22 @@ var init_table7 = __esm(() => {
35671
35682
  uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
35672
35683
  index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
35673
35684
  ]);
35685
+ gameTimebackActivityCompletions = pgTable("game_timeback_activity_completions", {
35686
+ id: uuid("id").primaryKey().defaultRandom(),
35687
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
35688
+ courseId: text("course_id").notNull(),
35689
+ studentId: text("student_id").notNull(),
35690
+ runId: uuid("run_id").notNull(),
35691
+ activityId: text("activity_id").notNull(),
35692
+ completedAt: timestamp("completed_at", { withTimezone: true }),
35693
+ reservedAt: timestamp("reserved_at", { withTimezone: true, mode: "string" }).notNull().defaultNow(),
35694
+ xpAwarded: doublePrecision("xp_awarded"),
35695
+ masteredUnitsApplied: integer("mastered_units_applied"),
35696
+ pctCompleteApp: doublePrecision("pct_complete_app"),
35697
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
35698
+ }, (table3) => [
35699
+ uniqueIndex("game_timeback_activity_completions_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId, table3.activityId)
35700
+ ]);
35674
35701
  });
35675
35702
  var exports_tables_index = {};
35676
35703
  __export(exports_tables_index, {
@@ -35686,6 +35713,7 @@ __export(exports_tables_index, {
35686
35713
  gameTimebackIntegrations: () => gameTimebackIntegrations,
35687
35714
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
35688
35715
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
35716
+ gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
35689
35717
  gameScoresRelations: () => gameScoresRelations,
35690
35718
  gameScores: () => gameScores,
35691
35719
  gamePlatformEnum: () => gamePlatformEnum,
@@ -51123,11 +51151,18 @@ var METRICS_BASE;
51123
51151
  var init_tunnel = __esm(() => {
51124
51152
  METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
51125
51153
  });
51154
+ function isPreviewStage(stage) {
51155
+ return PREVIEW_STAGE_PATTERN.test(stage);
51156
+ }
51157
+ var PREVIEW_STAGE_PATTERN;
51158
+ var init_stages = __esm(() => {
51159
+ PREVIEW_STAGE_PATTERN = /^pr-\d+$/;
51160
+ });
51126
51161
  function getDeploymentId(gameSlug, sstStage) {
51127
51162
  if (sstStage === "production") {
51128
51163
  return gameSlug;
51129
51164
  }
51130
- if (sstStage === "dev" || sstStage.startsWith("pr-")) {
51165
+ if (sstStage === "dev" || isPreviewStage(sstStage)) {
51131
51166
  return `${WORKER_NAMING.STAGING_PREFIX}${gameSlug}`;
51132
51167
  }
51133
51168
  return `${WORKER_NAMING.LOCAL_PREFIX}${sstStage}-${gameSlug}`;
@@ -51147,6 +51182,7 @@ async function generateDeploymentHash(code) {
51147
51182
  }
51148
51183
  var init_deployment_util = __esm(() => {
51149
51184
  init_src();
51185
+ init_stages();
51150
51186
  });
51151
51187
 
51152
51188
  class DeployService {
@@ -100027,7 +100063,8 @@ class ActivityRecord {
100027
100063
  courseName,
100028
100064
  subject: progressData.subject,
100029
100065
  appName: progressData.appName,
100030
- sensorUrl: progressData.sensorUrl
100066
+ sensorUrl: progressData.sensorUrl,
100067
+ eventId: progressData.completionHistoryEventId
100031
100068
  });
100032
100069
  }
100033
100070
  if (masteryProgress?.masteryRevoked) {
@@ -100052,6 +100089,7 @@ class ActivityRecord {
100052
100089
  subject: progressData.subject,
100053
100090
  appName: progressData.appName,
100054
100091
  sensorUrl: progressData.sensorUrl,
100092
+ eventId: progressData.eventId,
100055
100093
  extensions: extensions || progressData.extensions,
100056
100094
  ...progressData.runId ? { runId: progressData.runId } : {}
100057
100095
  }).catch((error88) => {
@@ -100075,6 +100113,7 @@ class ActivityRecord {
100075
100113
  }
100076
100114
  async emitCourseCompletionHistoryEvent(data) {
100077
100115
  await this.events.emitActivityEvent({
100116
+ eventId: data.eventId,
100078
100117
  studentId: data.studentId,
100079
100118
  studentEmail: data.studentEmail,
100080
100119
  gameId: data.gameId,
@@ -100190,6 +100229,7 @@ class ActivitySession {
100190
100229
  subject: sessionData.subject,
100191
100230
  appName: sessionData.appName,
100192
100231
  sensorUrl: sessionData.sensorUrl,
100232
+ eventId: sessionData.eventId,
100193
100233
  ...runId ? { runId } : {},
100194
100234
  ...extensions ? { extensions } : {}
100195
100235
  });
@@ -100456,6 +100496,9 @@ class ActivityEvents {
100456
100496
  } : {},
100457
100497
  ...eventExtensions ? { extensions: eventExtensions } : {}
100458
100498
  });
100499
+ if (data.eventId) {
100500
+ event.id = data.eventId;
100501
+ }
100459
100502
  await this.core.api.caliper.events.send(data.sensorUrl, [event]);
100460
100503
  }
100461
100504
  async emitTimeSpentEvent(data) {
@@ -100487,6 +100530,9 @@ class ActivityEvents {
100487
100530
  ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
100488
100531
  ...eventExtensions ? { extensions: eventExtensions } : {}
100489
100532
  });
100533
+ if (data.eventId) {
100534
+ event.id = data.eventId;
100535
+ }
100490
100536
  const wireEvent = data.extensions ? { ...event, generated: { ...event.generated, extensions: data.extensions } } : event;
100491
100537
  await this.core.api.caliper.events.send(data.sensorUrl, [wireEvent]);
100492
100538
  }
@@ -105544,6 +105590,9 @@ var init_timeback_service = __esm(async () => {
105544
105590
  static HEARTBEAT_DEDUPE_TTL_MS = 300000;
105545
105591
  static processedHeartbeatWindows = new Map;
105546
105592
  static inFlightHeartbeatWindows = new Map;
105593
+ static COMPLETION_RESERVATION_STALE_SECONDS = 300;
105594
+ static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
105595
+ static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
105547
105596
  deps;
105548
105597
  static cleanHeartbeatDedupeCache(now2 = Date.now()) {
105549
105598
  for (const [key, timestamp6] of this.processedHeartbeatWindows) {
@@ -105568,6 +105617,136 @@ var init_timeback_service = __esm(async () => {
105568
105617
  static clearInFlightHeartbeatWindow(key) {
105569
105618
  this.inFlightHeartbeatWindows.delete(key);
105570
105619
  }
105620
+ static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
105621
+ const hash2 = createHash2("sha256").update([
105622
+ "playcademy",
105623
+ "timeback",
105624
+ kind,
105625
+ key.gameId,
105626
+ key.courseId,
105627
+ key.studentId,
105628
+ key.runId,
105629
+ key.activityId,
105630
+ discriminator
105631
+ ].join("\x00")).digest("hex");
105632
+ const variant = (Number.parseInt(hash2.slice(16, 18), 16) & 63 | 128).toString(16).padStart(2, "0");
105633
+ const uuid9 = [
105634
+ hash2.slice(0, 8),
105635
+ hash2.slice(8, 12),
105636
+ `5${hash2.slice(13, 16)}`,
105637
+ `${variant}${hash2.slice(18, 20)}`,
105638
+ hash2.slice(20, 32)
105639
+ ].join("-");
105640
+ return `urn:uuid:${uuid9}`;
105641
+ }
105642
+ static async reserveCompletion(db2, key) {
105643
+ const [reserved] = await db2.insert(gameTimebackActivityCompletions).values({
105644
+ gameId: key.gameId,
105645
+ courseId: key.courseId,
105646
+ studentId: key.studentId,
105647
+ runId: key.runId,
105648
+ activityId: key.activityId
105649
+ }).onConflictDoUpdate({
105650
+ target: [
105651
+ gameTimebackActivityCompletions.gameId,
105652
+ gameTimebackActivityCompletions.courseId,
105653
+ gameTimebackActivityCompletions.studentId,
105654
+ gameTimebackActivityCompletions.runId,
105655
+ gameTimebackActivityCompletions.activityId
105656
+ ],
105657
+ set: { reservedAt: sql`now()` },
105658
+ setWhere: sql`${gameTimebackActivityCompletions.completedAt} IS NULL AND ${gameTimebackActivityCompletions.reservedAt} < now() - make_interval(secs => ${TimebackService2.COMPLETION_RESERVATION_STALE_SECONDS})`
105659
+ }).returning({
105660
+ id: gameTimebackActivityCompletions.id,
105661
+ reservedAt: gameTimebackActivityCompletions.reservedAt,
105662
+ takenOver: sql`${gameTimebackActivityCompletions.reservedAt} <> ${gameTimebackActivityCompletions.createdAt}`
105663
+ });
105664
+ if (reserved) {
105665
+ return {
105666
+ status: "reserved",
105667
+ token: { id: reserved.id, reservedAt: reserved.reservedAt },
105668
+ takenOver: reserved.takenOver
105669
+ };
105670
+ }
105671
+ const existing = await db2.query.gameTimebackActivityCompletions.findFirst({
105672
+ 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)),
105673
+ columns: {
105674
+ completedAt: true,
105675
+ xpAwarded: true,
105676
+ masteredUnitsApplied: true,
105677
+ pctCompleteApp: true
105678
+ }
105679
+ });
105680
+ if (!existing?.completedAt) {
105681
+ return { status: "in_flight" };
105682
+ }
105683
+ return {
105684
+ status: "duplicate",
105685
+ award: existing.xpAwarded !== null ? {
105686
+ xpAwarded: existing.xpAwarded,
105687
+ masteredUnitsApplied: existing.masteredUnitsApplied ?? 0,
105688
+ pctCompleteApp: existing.pctCompleteApp
105689
+ } : null
105690
+ };
105691
+ }
105692
+ static async acquireCompletionReservation(db2, key) {
105693
+ let reserveOutcome = await this.reserveCompletion(db2, key);
105694
+ for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
105695
+ await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
105696
+ reserveOutcome = await this.reserveCompletion(db2, key);
105697
+ }
105698
+ if (reserveOutcome.status === "duplicate") {
105699
+ setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
105700
+ return reserveOutcome;
105701
+ }
105702
+ if (reserveOutcome.status === "in_flight") {
105703
+ setAttribute("app.timeback.end_activity_status", "completion_reservation_in_flight");
105704
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
105705
+ }
105706
+ if (reserveOutcome.takenOver) {
105707
+ setAttribute("app.timeback.completion_reservation_takeover", true);
105708
+ addEvent("timeback.completion_reservation_takeover", {
105709
+ "app.timeback.run_id": key.runId,
105710
+ "app.timeback.activity_id": key.activityId
105711
+ });
105712
+ }
105713
+ return { status: "reserved", token: reserveOutcome.token };
105714
+ }
105715
+ static async runWithCompletionReservation(db2, key, emit, awardFromResult) {
105716
+ const acquired = await this.acquireCompletionReservation(db2, key);
105717
+ if (acquired.status === "duplicate") {
105718
+ return acquired;
105719
+ }
105720
+ const logContext = { run_id: key.runId, activity_id: key.activityId };
105721
+ let result;
105722
+ try {
105723
+ result = await emit();
105724
+ } catch (error88) {
105725
+ try {
105726
+ await this.rollbackCompletion(db2, acquired.token);
105727
+ } catch (rollbackError) {
105728
+ logTimebackError("rollback completion reservation", rollbackError, logContext);
105729
+ }
105730
+ throw error88;
105731
+ }
105732
+ try {
105733
+ await this.confirmCompletion(db2, acquired.token, awardFromResult(result));
105734
+ } catch (confirmError) {
105735
+ logTimebackError("confirm completion reservation", confirmError, logContext);
105736
+ }
105737
+ return { status: "emitted", result };
105738
+ }
105739
+ static async confirmCompletion(db2, token, award) {
105740
+ await db2.update(gameTimebackActivityCompletions).set({
105741
+ completedAt: sql`now()`,
105742
+ xpAwarded: award.xpAwarded,
105743
+ masteredUnitsApplied: award.masteredUnitsApplied,
105744
+ pctCompleteApp: award.pctCompleteApp
105745
+ }).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
105746
+ }
105747
+ static async rollbackCompletion(db2, token) {
105748
+ await db2.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
105749
+ }
105571
105750
  static addResumeIdToExtensions(extensions, resumeId) {
105572
105751
  const base = extensions ?? {};
105573
105752
  const existingPlaycademy = base.playcademy;
@@ -106469,16 +106648,25 @@ var init_timeback_service = __esm(async () => {
106469
106648
  attributes: courseAttributes
106470
106649
  } = TimebackService2.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
106471
106650
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
106472
- const result = await client.activity.record(runtimeCourseId, studentId, {
106651
+ const dedupeKey = runId !== undefined ? {
106652
+ gameId,
106653
+ courseId: runtimeCourseId,
106654
+ studentId,
106655
+ runId,
106656
+ activityId: activityData.activityId
106657
+ } : undefined;
106658
+ setAttributes({
106659
+ ...courseAttributes,
106660
+ "app.timeback.grade": activityData.grade,
106661
+ "app.timeback.subject": activityData.subject,
106662
+ "app.timeback.total_questions": scoreData.totalQuestions,
106663
+ "app.timeback.correct_questions": scoreData.correctQuestions,
106664
+ "app.timeback.score_pct": scorePercentage,
106665
+ "app.timeback.xp_requested": xpEarned
106666
+ });
106667
+ const courseTotalXp = integration.totalXp;
106668
+ const activityEventContext = {
106473
106669
  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
106670
  activityId: activityData.activityId,
106483
106671
  activityName: activityData.activityName,
106484
106672
  subject: activityData.subject,
@@ -106487,45 +106675,92 @@ var init_timeback_service = __esm(async () => {
106487
106675
  courseId: runtimeCourseId,
106488
106676
  courseName: runtimeCourseName,
106489
106677
  studentEmail: activityData.studentEmail,
106490
- courseTotalXp: integration.totalXp,
106678
+ extensions: extensionsWithResumeId,
106491
106679
  ...runId ? { runId } : {}
106492
- });
106680
+ };
106681
+ const completionEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
106682
+ const sessionEndEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
106683
+ const completionHistoryEventId = dedupeKey ? TimebackService2.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
106684
+ function emitRecord() {
106685
+ return client.activity.record(runtimeCourseId, studentId, {
106686
+ ...activityEventContext,
106687
+ eventId: completionEventId,
106688
+ completionHistoryEventId,
106689
+ score: scorePercentage,
106690
+ totalQuestions: scoreData.totalQuestions,
106691
+ correctQuestions: scoreData.correctQuestions,
106692
+ durationSeconds: timingData.durationSeconds,
106693
+ xpEarned,
106694
+ masteredUnits,
106695
+ masteredUnitsAbsolute,
106696
+ courseTotalXp
106697
+ });
106698
+ }
106699
+ let result;
106700
+ let blockedByDedupeGuard = false;
106701
+ let duplicateAward = null;
106702
+ if (dedupeKey) {
106703
+ const outcome = await TimebackService2.runWithCompletionReservation(db2, dedupeKey, emitRecord, (emitted) => ({
106704
+ xpAwarded: emitted.xpAwarded,
106705
+ masteredUnitsApplied: emitted.masteredUnitsApplied,
106706
+ pctCompleteApp: emitted.pctCompleteApp ?? null
106707
+ }));
106708
+ if (outcome.status === "duplicate") {
106709
+ blockedByDedupeGuard = true;
106710
+ duplicateAward = outcome.award;
106711
+ } else {
106712
+ result = outcome.result;
106713
+ }
106714
+ } else {
106715
+ result = await emitRecord();
106716
+ }
106493
106717
  const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
106494
106718
  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
- });
106719
+ const sessionEndAttempted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
106720
+ let sessionEndEmitted = false;
106721
+ if (sessionEndAttempted) {
106722
+ try {
106723
+ await client.activity.session(runtimeCourseId, studentId, {
106724
+ ...activityEventContext,
106725
+ eventId: sessionEndEventId,
106726
+ activeTimeSeconds: sessionEndActiveSeconds,
106727
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {}
106728
+ });
106729
+ sessionEndEmitted = true;
106730
+ } catch (sessionError) {
106731
+ logTimebackError("emit session end after completion", sessionError, {
106732
+ run_id: runId,
106733
+ activity_id: activityData.activityId
106734
+ });
106735
+ }
106512
106736
  }
106513
106737
  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,
106738
+ "app.timeback.session_end_attempted": sessionEndAttempted,
106520
106739
  "app.timeback.session_end_emitted": sessionEndEmitted,
106521
106740
  "app.timeback.active_time_seconds": sessionEndActiveSeconds,
106522
106741
  "app.timeback.inactive_time_seconds": sessionEndInactiveSeconds ?? 0,
106523
- "app.timeback.xp_requested": xpEarned,
106524
- "app.timeback.xp_awarded": result.xpAwarded,
106742
+ "app.timeback.xp_awarded": result?.xpAwarded ?? 0,
106525
106743
  "app.timeback.mastered_units_requested": masteredUnits,
106526
106744
  "app.timeback.mastered_units_absolute_requested": masteredUnitsAbsolute,
106527
- "app.timeback.mastered_units_applied": result.masteredUnitsApplied
106745
+ "app.timeback.mastered_units_applied": result?.masteredUnitsApplied ?? 0,
106746
+ ...blockedByDedupeGuard ? {
106747
+ "app.timeback.xp_already_awarded": duplicateAward?.xpAwarded ?? 0,
106748
+ "app.timeback.mastered_units_already_applied": duplicateAward?.masteredUnitsApplied ?? 0
106749
+ } : {}
106528
106750
  });
106751
+ if (blockedByDedupeGuard) {
106752
+ return {
106753
+ status: "ok",
106754
+ courseId: runtimeCourseId,
106755
+ xpAwarded: duplicateAward?.xpAwarded ?? 0,
106756
+ masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
106757
+ ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
106758
+ blockedByDedupeGuard: true
106759
+ };
106760
+ }
106761
+ if (!result) {
106762
+ throw new InternalError("Completion emit did not produce a result");
106763
+ }
106529
106764
  return {
106530
106765
  status: "ok",
106531
106766
  courseId: runtimeCourseId,
@@ -165560,17 +165795,75 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
165560
165795
  init_sqliteSchema();
165561
165796
  init_sqliteSerializer();
165562
165797
  });
165563
- async function createDatabaseSchema(db2) {
165798
+ function isDuplicateObjectError(error89) {
165799
+ const code = error89.code;
165800
+ if (code && DUPLICATE_OBJECT_CODES.has(code)) {
165801
+ return true;
165802
+ }
165803
+ return /already exists/i.test(errorMessage(error89));
165804
+ }
165805
+ async function reconcileMissingColumns(db2, snapshot) {
165806
+ const tables = snapshot.tables ?? {};
165807
+ for (const table9 of Object.values(tables)) {
165808
+ for (const column6 of Object.values(table9.columns ?? {})) {
165809
+ const clauses = [
165810
+ `ALTER TABLE "${table9.name}" ADD COLUMN IF NOT EXISTS "${column6.name}" ${column6.type}`
165811
+ ];
165812
+ if (column6.default !== undefined) {
165813
+ clauses.push(`DEFAULT ${column6.default}`);
165814
+ }
165815
+ if (column6.notNull && !column6.primaryKey) {
165816
+ clauses.push("NOT NULL");
165817
+ }
165818
+ try {
165819
+ await db2.execute(clauses.join(" "));
165820
+ } catch (error89) {
165821
+ 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.`);
165822
+ }
165823
+ }
165824
+ }
165825
+ }
165826
+ async function ensureSchemaMetaTable(client) {
165827
+ await client.query(`CREATE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} (
165828
+ id integer PRIMARY KEY CHECK (id = 1),
165829
+ snapshot jsonb NOT NULL,
165830
+ updated_at timestamptz NOT NULL DEFAULT now()
165831
+ )`);
165832
+ }
165833
+ async function readStoredSnapshot(client) {
165834
+ const result = await client.query(`SELECT snapshot FROM ${SCHEMA_META_TABLE} WHERE id = 1`);
165835
+ return result.rows[0]?.snapshot ?? null;
165836
+ }
165837
+ async function writeStoredSnapshot(client, snapshot) {
165838
+ await client.query(`INSERT INTO ${SCHEMA_META_TABLE} (id, snapshot, updated_at)
165839
+ VALUES (1, $1, now())
165840
+ ON CONFLICT (id) DO UPDATE SET snapshot = $1, updated_at = now()`, [JSON.stringify(snapshot)]);
165841
+ }
165842
+ async function syncDatabaseSchema(db2, client, isExistingDb) {
165564
165843
  try {
165565
165844
  const { generateDrizzleJson: generateDrizzleJson2, generateMigration: generateMigration2 } = await Promise.resolve().then(() => (init_api4(), exports_api));
165566
- const prevJson = generateDrizzleJson2({});
165845
+ await ensureSchemaMetaTable(client);
165846
+ const stored = isExistingDb ? await readStoredSnapshot(client) : null;
165847
+ const emptyJson = generateDrizzleJson2({});
165848
+ const prevJson = stored ?? emptyJson;
165567
165849
  const curJson = generateDrizzleJson2(exports_tables_index, prevJson.id, undefined, "snake_case");
165568
165850
  const statements = await generateMigration2(prevJson, curJson);
165851
+ const tolerateExisting = isExistingDb && stored === null;
165569
165852
  for (const statement of statements) {
165570
- await db2.execute(statement);
165853
+ try {
165854
+ await db2.execute(statement);
165855
+ } catch (error89) {
165856
+ if (!(tolerateExisting && isDuplicateObjectError(error89))) {
165857
+ throw error89;
165858
+ }
165859
+ }
165860
+ }
165861
+ if (tolerateExisting) {
165862
+ await reconcileMissingColumns(db2, curJson);
165571
165863
  }
165864
+ await writeStoredSnapshot(client, curJson);
165572
165865
  } catch (error89) {
165573
- console.error("[Sandbox] Schema creation failed:", error89);
165866
+ console.error("[Sandbox] Schema sync failed:", error89);
165574
165867
  throw error89;
165575
165868
  }
165576
165869
  }
@@ -165581,16 +165874,17 @@ async function setupDatabase(customPath) {
165581
165874
  const client = dbPath === ":memory:" ? new We2 : new We2(dbPath);
165582
165875
  await client._checkReady();
165583
165876
  const db2 = drizzle(client, { schema: exports_tables_index });
165584
- if (!dbFileExists) {
165585
- await createDatabaseSchema(db2);
165586
- }
165877
+ await syncDatabaseSchema(db2, client, dbFileExists);
165587
165878
  return db2;
165588
165879
  }
165880
+ var SCHEMA_META_TABLE = "_sandbox_schema_meta";
165881
+ var DUPLICATE_OBJECT_CODES;
165589
165882
  var init_database = __esm(() => {
165590
165883
  init_dist8();
165591
165884
  init_pglite();
165592
165885
  init_tables_index();
165593
165886
  init_path_manager();
165887
+ DUPLICATE_OBJECT_CODES = new Set(["42701", "42710", "42723", "42P06", "42P07"]);
165594
165888
  });
165595
165889
  function setLogger(logger3) {
165596
165890
  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.3",
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.3"
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.4",
29
+ "@playcademy/sdk": "0.15.1-beta.2",
30
30
  "@playcademy/types": "0.0.1",
31
31
  "@playcademy/utils": "0.0.1",
32
32
  "@types/archiver": "^6.0.3",