lopata 0.4.3 → 0.5.0

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.
@@ -2408,6 +2408,12 @@
2408
2408
  }
2409
2409
  }
2410
2410
 
2411
+ @media (hover: hover) {
2412
+ .hover\:bg-red-700:hover {
2413
+ background-color: var(--color-red-700);
2414
+ }
2415
+ }
2416
+
2411
2417
  @media (hover: hover) {
2412
2418
  .hover\:bg-yellow-500\/25:hover {
2413
2419
  background-color: #edb20040;
@@ -4538,6 +4538,8 @@ function DoInstanceDetail({ ns, id, basePath, routeTab, routeTable, routeQuery }
4538
4538
  const { data, refetch } = useQuery("do.getInstance", { ns, id });
4539
4539
  const deleteEntry = useMutation("do.deleteEntry");
4540
4540
  const triggerAlarm = useMutation("do.triggerAlarm");
4541
+ const cancelAlarm = useMutation("do.cancelAlarm");
4542
+ const deleteInstance = useMutation("do.deleteInstance");
4541
4543
  const { data: sqlTables } = useQuery("do.listSqlTables", { ns, id });
4542
4544
  const handleDelete = async (key) => {
4543
4545
  if (!confirm(`Delete storage key "${key}"?`))
@@ -4549,6 +4551,16 @@ function DoInstanceDetail({ ns, id, basePath, routeTab, routeTable, routeQuery }
4549
4551
  await triggerAlarm.mutate({ ns, id });
4550
4552
  refetch();
4551
4553
  };
4554
+ const handleCancelAlarm = async () => {
4555
+ await cancelAlarm.mutate({ ns, id });
4556
+ refetch();
4557
+ };
4558
+ const handleDeleteInstance = async () => {
4559
+ if (!confirm(`Delete this Durable Object instance? All storage data will be permanently removed.`))
4560
+ return;
4561
+ await deleteInstance.mutate({ ns, id });
4562
+ location.hash = `#/do/${encodeURIComponent(ns)}`;
4563
+ };
4552
4564
  if (!data)
4553
4565
  return /* @__PURE__ */ u3("div", {
4554
4566
  class: "p-4 sm:p-8 text-text-muted font-medium",
@@ -4565,23 +4577,42 @@ function DoInstanceDetail({ ns, id, basePath, routeTab, routeTable, routeQuery }
4565
4577
  ]
4566
4578
  }, undefined, false, undefined, this),
4567
4579
  /* @__PURE__ */ u3("div", {
4568
- class: "mb-6 flex justify-end",
4569
- children: /* @__PURE__ */ u3(RefreshButton, {
4570
- onClick: refetch
4571
- }, undefined, false, undefined, this)
4572
- }, undefined, false, undefined, this),
4580
+ class: "mb-6 flex justify-end gap-2",
4581
+ children: [
4582
+ /* @__PURE__ */ u3("button", {
4583
+ onClick: handleDeleteInstance,
4584
+ disabled: deleteInstance.isLoading,
4585
+ class: "rounded-md px-3 py-1.5 text-xs font-medium bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all",
4586
+ children: deleteInstance.isLoading ? "Deleting..." : "Delete instance"
4587
+ }, undefined, false, undefined, this),
4588
+ /* @__PURE__ */ u3(RefreshButton, {
4589
+ onClick: refetch
4590
+ }, undefined, false, undefined, this)
4591
+ ]
4592
+ }, undefined, true, undefined, this),
4573
4593
  (data.alarm || data.hasAlarmHandler) && /* @__PURE__ */ u3("div", {
4574
4594
  class: "mb-6 px-4 py-3 bg-panel-secondary border border-border rounded-lg text-sm font-medium text-ink flex items-center justify-between",
4575
4595
  children: [
4576
4596
  /* @__PURE__ */ u3("span", {
4577
4597
  children: data.alarm ? `Alarm set for: ${formatTime(data.alarm)}` : "No alarm scheduled"
4578
4598
  }, undefined, false, undefined, this),
4579
- data.hasAlarmHandler && /* @__PURE__ */ u3("button", {
4580
- onClick: handleTriggerAlarm,
4581
- disabled: triggerAlarm.isLoading,
4582
- class: "rounded-md px-3 py-1.5 text-xs font-medium bg-ink text-surface hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed transition-all",
4583
- children: triggerAlarm.isLoading ? "Triggering..." : "Trigger now"
4584
- }, undefined, false, undefined, this)
4599
+ /* @__PURE__ */ u3("div", {
4600
+ class: "flex gap-2",
4601
+ children: [
4602
+ data.alarm && /* @__PURE__ */ u3("button", {
4603
+ onClick: handleCancelAlarm,
4604
+ disabled: cancelAlarm.isLoading,
4605
+ class: "rounded-md px-3 py-1.5 text-xs font-medium bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all",
4606
+ children: cancelAlarm.isLoading ? "Cancelling..." : "Cancel"
4607
+ }, undefined, false, undefined, this),
4608
+ data.hasAlarmHandler && /* @__PURE__ */ u3("button", {
4609
+ onClick: handleTriggerAlarm,
4610
+ disabled: triggerAlarm.isLoading,
4611
+ class: "rounded-md px-3 py-1.5 text-xs font-medium bg-ink text-surface hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed transition-all",
4612
+ children: triggerAlarm.isLoading ? "Triggering..." : "Trigger now"
4613
+ }, undefined, false, undefined, this)
4614
+ ]
4615
+ }, undefined, true, undefined, this)
4585
4616
  ]
4586
4617
  }, undefined, true, undefined, this),
4587
4618
  data.entries.length === 0 ? /* @__PURE__ */ u3(EmptyState, {
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
10
10
 
11
- <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-paesqsyf.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-ymq225fp.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-a68x1m5f.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-rae638a4.js"></script></head>
12
12
  <body class="h-full bg-surface text-ink" style="font-family: system-ui, -apple-system, sans-serif;">
13
13
  <script>
14
14
  // Apply saved theme before first paint to prevent flash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -94,6 +94,20 @@ export const handlers = {
94
94
  return { ok: true }
95
95
  },
96
96
 
97
+ 'do.cancelAlarm'({ ns, id }: { ns: string; id: string }, ctx: HandlerContext): OkResponse {
98
+ const namespace = getDoNamespace(ctx, ns)
99
+ if (!namespace) throw new Error(`Durable Object namespace "${ns}" not found (worker not loaded?)`)
100
+ namespace.cancelAlarm(id)
101
+ return { ok: true }
102
+ },
103
+
104
+ 'do.deleteInstance'({ ns, id }: { ns: string; id: string }, ctx: HandlerContext): OkResponse {
105
+ const namespace = getDoNamespace(ctx, ns)
106
+ if (!namespace) throw new Error(`Durable Object namespace "${ns}" not found (worker not loaded?)`)
107
+ namespace.deleteInstance(id)
108
+ return { ok: true }
109
+ },
110
+
97
111
  async 'do.generateSql'({ ns, id, prompt }: { ns: string; id: string; prompt: string }, ctx: HandlerContext): Promise<{ sql: string }> {
98
112
  const dbPath = join(getDataDir(), 'do-sql', ns, `${id}.sqlite`)
99
113
  if (!existsSync(dbPath)) throw new Error('SQL database not found for this instance')
@@ -1,5 +1,5 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite'
2
- import { mkdirSync, statSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { persistError, startSpan } from '../tracing/span'
5
5
  import type { ContainerContext } from './container'
@@ -952,6 +952,54 @@ export class DurableObjectNamespaceImpl {
952
952
  return this._fireAlarm(idStr, 0)
953
953
  }
954
954
 
955
+ /** @internal Cancel a scheduled alarm without firing it */
956
+ cancelAlarm(idStr: string): void {
957
+ const timer = this.alarmTimers.get(idStr)
958
+ if (timer) clearTimeout(timer)
959
+ this.alarmTimers.delete(idStr)
960
+ this.db
961
+ .query('DELETE FROM do_alarms WHERE namespace = ? AND id = ?')
962
+ .run(this.namespaceName, idStr)
963
+ }
964
+
965
+ /** @internal Delete a DO instance and all its data */
966
+ deleteInstance(idStr: string): void {
967
+ this.cancelAlarm(idStr)
968
+
969
+ const executor = this._executors.get(idStr)
970
+ if (executor) {
971
+ executor.dispose().catch(() => {})
972
+ this._executors.delete(idStr)
973
+ }
974
+ this._stubs.delete(idStr)
975
+ this._knownIds.delete(idStr)
976
+ this._lastActivity.delete(idStr)
977
+
978
+ this.db.run('BEGIN')
979
+ try {
980
+ this.db.query('DELETE FROM do_instances WHERE namespace = ? AND id = ?').run(this.namespaceName, idStr)
981
+ this.db.query('DELETE FROM do_storage WHERE namespace = ? AND id = ?').run(this.namespaceName, idStr)
982
+ this.db.query('DELETE FROM do_alarms WHERE namespace = ? AND id = ?').run(this.namespaceName, idStr)
983
+ this.db.run('COMMIT')
984
+ } catch (e) {
985
+ this.db.run('ROLLBACK')
986
+ throw e
987
+ }
988
+
989
+ // Delete SQL storage database files
990
+ if (this.dataDir) {
991
+ const sqlDbPath = join(this.dataDir, 'do-sql', this.namespaceName, `${idStr}.sqlite`)
992
+ if (existsSync(sqlDbPath)) {
993
+ rmSync(sqlDbPath, { force: true })
994
+ // Clean up WAL/SHM files
995
+ for (const suffix of ['-wal', '-shm']) {
996
+ const walPath = sqlDbPath + suffix
997
+ if (existsSync(walPath)) rmSync(walPath, { force: true })
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
955
1003
  newUniqueId(_options?: { jurisdiction?: string }): DurableObjectIdImpl {
956
1004
  return new DurableObjectIdImpl(crypto.randomUUID().replace(/-/g, ''))
957
1005
  }
@@ -19,7 +19,7 @@ class LopataDevEnvironment extends DevEnvironment {
19
19
 
20
20
  get runner(): ModuleRunner {
21
21
  if (!this._runner) {
22
- this._runner = createServerModuleRunner(this)
22
+ this._runner = createServerModuleRunner(this, { hmr: false })
23
23
  }
24
24
  return this._runner
25
25
  }
@@ -52,8 +52,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
52
52
  let currentModule: Record<string, unknown> | null = null
53
53
  // Serializes module reload — prevents concurrent wireClassRefs calls
54
54
  let reloadLock: Promise<void> | null = null
55
- // Flag set on file change; next request will clear runner cache
56
- let needsReload = false
55
+ // Files changed since last request coalesced into a single invalidation batch
56
+ let changedFiles: Set<string> = new Set()
57
57
 
58
58
  return {
59
59
  name: 'lopata:dev-server',
@@ -168,18 +168,17 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
168
168
  }
169
169
 
170
170
  // 5. Track SSR-relevant file changes.
171
- // Vite's built-in HMR flow invalidates the module graph and sends
172
- // full-reload events to the runner. However, the runner's async HMR
173
- // handler can race with incoming requests. We set a flag here and
174
- // clear the runner's evaluated module cache on the next request,
175
- // ensuring a clean re-evaluation from the freshly-invalidated module graph.
171
+ // Collect changed file paths; the next request will invalidate only
172
+ // the affected modules (and their transitive importers) instead of
173
+ // clearing the entire runner cache. HMR on the runner is disabled
174
+ // (hmr: false in config-plugin) so there's no async race.
176
175
  server.watcher.on('change', (file) => {
177
176
  const ssrEnv = server.environments[options.envName]
178
177
  if (!ssrEnv) return
179
178
  const normalizedFile = file.replace(/\\/g, '/')
180
179
  const mods = ssrEnv.moduleGraph.getModulesByFile(normalizedFile)
181
180
  if (mods && mods.size > 0) {
182
- needsReload = true
181
+ changedFiles.add(normalizedFile)
183
182
  currentModule = null
184
183
  }
185
184
  })
@@ -244,17 +243,18 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
244
243
  // Wait for any in-progress reload before importing
245
244
  if (reloadLock) await reloadLock
246
245
 
247
- // Clear runner's evaluated module cache when files changed.
248
- // The module graph is already invalidated by Vite's onFileChange,
249
- // but the runner caches evaluated modules independently.
250
- // Using .clear() (like the Cloudflare plugin) wipes all three
251
- // maps (idToModuleMap, fileToModulesMap, urlToIdModuleMap),
252
- // forcing a full re-evaluation from the fresh module graph.
253
- if (needsReload) {
254
- needsReload = false
255
- const runner = (ssrEnv as { runner: { evaluatedModules: { clear(): void } } }).runner
256
- runner.evaluatedModules.clear()
257
- console.log('[lopata:vite] Cleared runner module cache (file change detected)')
246
+ // Granular invalidation: only invalidate modules for changed
247
+ // files and their transitive importers, instead of wiping the
248
+ // entire runner cache. This preserves cached evaluations of
249
+ // unchanged modules for faster re-evaluation.
250
+ if (changedFiles.size > 0) {
251
+ const files = changedFiles
252
+ changedFiles = new Set()
253
+ const runner = (ssrEnv as any).runner
254
+ const count = invalidateChangedModules(runner.evaluatedModules, files)
255
+ if (count > 0) {
256
+ console.log(`[lopata:vite] Invalidated ${count} module(s) (${files.size} file(s) changed)`)
257
+ }
258
258
  }
259
259
 
260
260
  const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
@@ -452,6 +452,52 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
452
452
  }
453
453
  }
454
454
 
455
+ /**
456
+ * Invalidate runner-evaluated modules for the given changed files and all
457
+ * their transitive importers. Virtual modules (IDs starting with `\0`) are
458
+ * skipped to preserve shimmed CF modules (see commit 75b1736).
459
+ *
460
+ * Returns the number of modules invalidated.
461
+ */
462
+ function invalidateChangedModules(
463
+ evaluatedModules: { getModulesByFile(file: string): Iterable<{ id: string; importers: Set<any> }> | undefined; invalidateModule(node: any): void },
464
+ changedFiles: Set<string>,
465
+ ): number {
466
+ const toInvalidate = new Set<{ id: string; importers: Set<any> }>()
467
+
468
+ for (const file of changedFiles) {
469
+ const nodes = evaluatedModules.getModulesByFile(file)
470
+ if (!nodes) continue
471
+ for (const node of nodes) {
472
+ collectTransitiveImporters(node, toInvalidate)
473
+ }
474
+ }
475
+
476
+ for (const node of toInvalidate) {
477
+ evaluatedModules.invalidateModule(node)
478
+ }
479
+
480
+ return toInvalidate.size
481
+ }
482
+
483
+ /**
484
+ * Collect `node` and all its transitive importers into `result`.
485
+ * Skips virtual modules (IDs starting with `\0`) — these are CF module shims
486
+ * that must not be invalidated or traversed further.
487
+ */
488
+ function collectTransitiveImporters(
489
+ node: { id: string; importers: Set<any> },
490
+ result: Set<{ id: string; importers: Set<any> }>,
491
+ ): void {
492
+ if (result.has(node)) return
493
+ // Skip virtual modules — CF shims must stay cached
494
+ if (node.id.startsWith('\0')) return
495
+ result.add(node)
496
+ for (const importer of node.importers) {
497
+ collectTransitiveImporters(importer, result)
498
+ }
499
+ }
500
+
455
501
  function stitchAsyncStack(err: Error, callerError: Error | null): void {
456
502
  if (!callerError) return
457
503
  if (!err.stack || !callerError.stack) return