akanjs 2.4.1-rc.7 → 2.4.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,331 @@
1
1
  # akanjs
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 473be34: fix(devkit): restart the backend for server code saved while the builder was away
8
+
9
+ Nothing watches the source tree between a builder leaving and its replacement being ready: the idle
10
+ suspend stops its own watcher before the wake's boot build, a recycle or crash takes the builder's
11
+ watcher with it, and a fresh builder primes its mtime index from whatever it finds on disk — so a save
12
+ that lands in that window is _baseline_ to it and produces no event anywhere. The client half of such a
13
+ save is rescued by the boot build reading the new file; the backend half was not. A `.service.ts` saved
14
+ in that window left the server running the code it replaced, with nothing on screen to say so.
15
+
16
+ The dev host now stamps `(mtime, size)` for every file in the backend import graph when the builder goes
17
+ away — at suspend, at a recycle request, at a crash exit — and compares them when a builder is ready
18
+ again, restarting the backend for anything that moved. Comparing stamps rather than waiting for events
19
+ also covers the watcher dropping one, which Bun's recursive `fs.watch` does.
20
+
21
+ A config change while suspended is exempt, because it replaces the backend along with the builder on its
22
+ own. Where the backend graph scan has never succeeded — path-role fallback rules — there is nothing to
23
+ stamp, and the host says so rather than staying quiet about it.
24
+
25
+ - f5bfa27: perf(devkit): stop parsing every source file to find barrel imports
26
+
27
+ `rewriteBarrelImports` ran a full TypeScript parse of every file it was given, to find import
28
+ statements it then discarded for all but the barrel ones. It runs on every source file of every dev
29
+ rebuild, which made it the single most expensive thing in one.
30
+
31
+ - **63% of files import no barrel at all.** A static import cannot name a specifier without that
32
+ specifier appearing literally in the source, so a substring test skips them before the parser is
33
+ involved — 4ms for 1189 files. The bundler plugin already did this privately; it now lives in
34
+ `rewriteBarrelImports` so the CSS and client-entry walks get it too.
35
+ - **`setParentNodes: true` was paid for no reader.** Nothing reads `node.parent`; every position comes
36
+ from `getStart(sourceFile)`, which takes the file explicitly.
37
+
38
+ Measured across 1189 files: 299ms and 161MB of RSS become 88ms and 5MB. End to end on `apps/akan`,
39
+ `CssCompiler.discoverCssAndSources` drops from 262ms to 190ms and the client-entry discovery walk from
40
+ 242ms to 176ms, retaining 107MB instead of 128MB. Verified output-identical on 1535 files: same import
41
+ statements from the parser, and no file the pre-filter skips would have been rewritten.
42
+
43
+ `CssCompiler` also memoises import resolution for the life of one rebuild, where a miss cost up to 13
44
+ sequential `exists()` calls repeated per importer (a further 216ms → 190ms).
45
+
46
+ - 473be34: fix(devkit): bound the `ps` fallback that reads another process's memory
47
+
48
+ Where there is no `/proc` (macOS), the dev host reads a process's RSS by shelling out to `ps`, with no
49
+ timeout. An absent `ps` was already handled — it answers `null`, which callers read as "no new
50
+ information" — but a stuck one was not, and its only caller awaits it at the end of a 20s settle before
51
+ committing a builder recycle. A hang there meant the recycle silently never happened.
52
+
53
+ Now spawned directly with a 2s kill timer, which is the same treatment the dev-stability harness already
54
+ needed after `ps` hung under load.
55
+
56
+ - 068158b: feat(devkit): bound dev-server memory and stop losing watch events
57
+
58
+ Two dev-server problems that compounded each other.
59
+
60
+ - The builder grew without bound because `Bun.build` retains native bundler arenas that
61
+ `Bun.gc(true)` never reclaims. It is now recycled once its RSS passes a ceiling derived
62
+ from the container's cgroup limit, draining in flight work first.
63
+ - Bun's recursive `fs.watch` reports roughly one path per coalescing window and discards
64
+ the rest, so concurrent saves went unbuilt. Changes are now resolved against a
65
+ `SourceMtimeIndex` baseline and events only decide _when_ to look.
66
+
67
+ - 46a1a4a: fix(devkit): flush all builder ipc through BuilderChannel and isolate the boot build
68
+
69
+ `BuilderReply` only covered request responses, so recycle `process.exit` could still
70
+ drop unflushed events like `css-updated` and leave the backend on a stale bundle. The
71
+ boot `SsrBaseArtifactBuilder` also stayed in the long-lived watcher and retained most of
72
+ its idle RSS.
73
+
74
+ - Replace `BuilderReply` with `BuilderChannel` (`send` / `emit` / `drain`) so every
75
+ builder→host message awaits ipc flush before recycle exit.
76
+ - Run the boot base artifact build in the disposable `buildBatch` worker (`needs: ["base"]`)
77
+ and keep only the serializable artifact/fonts in the watcher.
78
+ - Split the idle resource-budget assertion so host+backend stays tight while the builder
79
+ can swing within its RSS recycle ceiling.
80
+
81
+ - 90c6597: fix(devkit): answer in-flight builder requests on recycle and flush replies before exit
82
+
83
+ Builder RSS recycle (and unexpected exits) left mid-flight `build-route` /
84
+ `build-csr` promises hanging: the host never tracked correlation ids, and even a
85
+ clean drain could lose a large `build-route-res` when `process.exit` truncated an
86
+ unflushed ipc write past the pipe buffer.
87
+
88
+ - Track in-flight ids on the host and fail them with a reloadable error when the
89
+ builder recycles, crashes, or is stopped.
90
+ - Send replies through `BuilderReply`, which awaits the ipc flush (with a timeout)
91
+ so recycle drain means "answered", not "truncated".
92
+
93
+ - aca901d: fix(devkit): keep builder replies matched to the backend that asked for them
94
+
95
+ `BuilderRpc` numbers its requests from 1 in each backend _process_, while the builder it
96
+ talks to outlives the backend. After a restart the two generations collided on id 1: the
97
+ builder answered the departed backend's request, the dev host relayed it, and the new
98
+ backend settled its own id 1 with another route's manifest delta — a page rendered against
99
+ client modules that were never built for it. The answer it was actually waiting for then
100
+ arrived to an empty pending map and was dropped, discarding the correct build too.
101
+
102
+ `BuilderRequestRouter` renumbers ids host-side, so neither the backend nor the builder
103
+ learns anything changed and a reply whose generation is gone is discarded rather than
104
+ misdelivered.
105
+
106
+ - 068158b: fix(cli): make `bun run akan <cmd>` concurrency-safe
107
+
108
+ `bun run akan` rebuilds the CLI into a shared `dist/` before every command, so two
109
+ commands started at once could read a half-written bundle.
110
+
111
+ - cb895b7: fix(devkit): find created directories on a filesystem with a coarse mtime clock
112
+
113
+ `SourceMtimeIndex` finds new files by noticing their directory's mtime moved, which Linux
114
+ stamps from a coarse clock: 400 back-to-back `mkdir`s left the parent's mtime unmoved 319
115
+ times on overlayfs and 324 times on ext4, smallest observable step 1ms. macOS APFS
116
+ (0.042ms) missed none, which is why this only ever showed up on Linux.
117
+
118
+ A directory mutated in the same millisecond as the recorded value — but after the walk that
119
+ recorded it — therefore left no trace, and because its files were never tracked, later edits
120
+ to them went unreported for the life of the process. Directories whose mtime was still fresh
121
+ when it was read are now re-walked on the next scan (`dirSettleMs`, 20ms default), and
122
+ `HmrWatcher` schedules one more scan while any remain unsettled.
123
+
124
+ - f8a9bc5: perf(devkit): cache tailwind candidate tokens across builds
125
+
126
+ The CSS rebuild read the full text of every source file on every save. Phase 2 moved css
127
+ compilation into a per-generation batch worker, so an in-memory cache is discarded before
128
+ the next save can use it — the cache goes to disk instead, the same way font subsetting
129
+ does, which also survives a builder recycle and a dev-host restart.
130
+
131
+ Measured on `apps/akan` (556 sources, 26800 candidates): the candidate scan drops from
132
+ 58-60ms to 13-19ms. Note that this is a smaller share of the rebuild than expected — the
133
+ full CSS rebuild is ~380ms, so the scan was never the dominant cost. Nothing is written
134
+ when nothing was re-read.
135
+
136
+ - d973712: perf(cli): keep heavy dependencies out of the long-lived dev processes
137
+
138
+ `akan start` holds the CLI entry and the builder watcher for the whole dev session, so an
139
+ eagerly imported dependency is resident for the whole session too.
140
+
141
+ - The dev host reached `@inquirer/prompts` (~24MB) because `runCommands` shares a module
142
+ with the interactive argument fallbacks. Those now load the prompt stack on first use,
143
+ which for `akan start` is never.
144
+ - The builder watcher reached `tailwindcss` and `@tailwindcss/node` (~40MB) through the
145
+ `frontendBuild` barrel, which re-exports `cssCompiler` and `ssrBaseArtifactBuilder`. That
146
+ has been dead weight since css compilation moved into the batch worker; the watcher now
147
+ imports by module path.
148
+
149
+ `entryModuleGraph.test.ts` guards both by walking each built entry's chunk closure. The
150
+ previous check grepped the entry file alone, which cannot see a dependency reached through
151
+ a shared chunk and so reported both of these as absent.
152
+
153
+ - 068158b: fix(devkit): pin the dev port and bound the waits that could hang forever
154
+
155
+ - `AKAN_DEV_PORT` pins the dev port. It used to derive from an app's index in the `apps/`
156
+ listing, so adding an app moved a running dev server's port at its next restart.
157
+ - `BuilderRpc` created request promises with no timeout, and nothing else answers a lost
158
+ request. Since the builder is recycled routinely, a page request that landed mid
159
+ route-build left the SSR promise pending forever with nothing to retry. Now bounded by
160
+ `AKAN_BUILDER_RPC_TIMEOUT_MS` (120s default) with a message naming the likely cause.
161
+
162
+ - cc3dd40: feat: expose local-dev metadata endpoints for devtools visualization
163
+
164
+ Add four JSON endpoints, registered only when `AKAN_PUBLIC_ENV=local` (override with `AKAN_DEVTOOLS`),
165
+ that describe the running system for an external developer-tools UI:
166
+
167
+ - `GET /_akan/constant` — every model's Input/Object/Full/Light/Insight view, scalars, enums, filter
168
+ query/sort, and derived relation edges.
169
+ - `GET /_akan/signal` — declared and framework-generated endpoints, slices, internals, and a flattened
170
+ route table with fully resolved HTTP/WS paths.
171
+ - `GET /_akan/dictionary` — the merged i18n tree, module kinds, and flattened dotted keys (`?lang=` narrows it).
172
+ - `GET /_akan/deps` — the DI graph: services, adaptors, signals, uses, middleware, env, roles, and the
173
+ topological init stages.
174
+
175
+ They live in `AkanServer.#createBuiltinRoutes()` next to `/openapi.json`, so they stay off the `/api` prefix
176
+ and never enter the `serializedSignal` payload shipped to clients. Outside `local` the routes are not
177
+ registered at all and fall through to the SSR catch-all.
178
+
179
+ Supporting changes:
180
+
181
+ - `DictionaryRegistry` collects each `makeTrans` root, which was previously closure-private and unreachable
182
+ from the server.
183
+ - `DiLifecycle` gains a read-only `modules` accessor and retains disabled-module reasons that were only logged.
184
+ - `SignalResolver.getScheduleSkipReason` is now public so the reported schedule placement cannot drift from
185
+ the scheduler's own rules.
186
+
187
+ Secrets discipline: secret constant fields report name and type but no `default`/`example`, `env` carries
188
+ values for `AKAN_PUBLIC_*` only and every other key by name alone, and `uses` are reported as key plus class
189
+ name — never the instance. `env` inject keys are extracted by scanning the factory source, never by running it.
190
+
191
+ - 8a2b795: perf(devkit): stop retaining source text in client-entry discovery, and expire its misses
192
+
193
+ `GraphClientEntryDiscovery` is created once per builder process, so its caches live for the
194
+ whole dev session in the watcher.
195
+
196
+ - It kept the full text of every file the walk had touched plus a barrel-rewritten copy of
197
+ each, when the walk only ever asks two things of a file — is it a client entry, and what
198
+ does it import — both of which were already cached separately under the same key. Those
199
+ three caches collapse into one holding just the derived facts: measured on `apps/akan`,
200
+ retention after a full walk drops from 135-142MB to 125-127MB.
201
+ - `invalidate()` never cleared the file-existence and resolution caches. They are keyed by
202
+ extension-less path and by `dir\0specifier`, neither of which maps back to a file that was
203
+ just created, so a negative recorded before a module existed was permanent: adding a new
204
+ module and importing it left the import unresolved until the next config change or builder
205
+ recycle. Negative answers are now dropped on any invalidate; positive ones are keyed by a
206
+ real path and are left alone.
207
+
208
+ - 068158b: perf(devkit): cache font subsetting across dev-server boots
209
+
210
+ `FontOptimizer.optimize()` re-subset every font file on each builder boot even though it
211
+ already computed a config hash and wrote hashed outputs. It now skips the
212
+ `fonteditor-core` / `subset-font` work when the expected outputs are present, which also
213
+ keeps those two packages out of the common path entirely.
214
+
215
+ - 473be34: fix(devkit): hold page requests during the recycle drain, not only after the builder exits
216
+
217
+ A builder asked to recycle drains first — it stays alive finishing its queued work and refuses
218
+ everything new — and throughout that window the dev host still reported it as `ready`. So a route or CSR
219
+ request that arrived during the drain was sent, refused by the departing builder, and relayed to the
220
+ backend as a failure: the same dev error page the request-holding fix was written to remove, in the half
221
+ of the window it never covered. The hold was unreachable there, because it only runs when the send
222
+ itself fails.
223
+
224
+ The builder host now reports `recycling` for the drain, which `send()` refuses on and which the hold
225
+ decision treats like a restart. `ready` — the field `onExit` reads to tell a planned exit from a builder
226
+ that never came up — is deliberately unchanged.
227
+
228
+ - e5fde3b: fix(devkit): hold page requests while the builder restarts instead of failing them
229
+
230
+ A route or CSR request that arrived while the builder was recycling or restarting was answered
231
+ immediately with `builder is restarting; reload after the builder is ready`. Nothing retried, so the
232
+ browser tab showed an error for a builder that was seconds from being back — and the builder is recycled
233
+ routinely, whenever its RSS passes the ceiling.
234
+
235
+ Those requests are now held and replayed when the builder reports ready, which is what the idle-suspend
236
+ path already did for exactly this reason. `BuilderRpc`'s own timeout still bounds the wait, the queue is
237
+ capped so a builder that never returns cannot grow it, and anything still held when the builder is
238
+ stopped for good is failed rather than left silent.
239
+
240
+ - 473be34: fix(devkit): say when a build worker was killed rather than crashed
241
+
242
+ The disposable build worker holds the largest transient in the dev tree (~548MB on a mid-size app, over
243
+ 1GB on a large one), so on a small sandbox it is the process the kernel reaches for first. A worker the
244
+ OOM killer takes exits with code `null` and `SIGKILL`, and the build was reported as `build worker exited
245
+ with code null before reporting a result` — indistinguishable from an ordinary crash, though the two have
246
+ opposite fixes: find the build error, or raise the memory limit.
247
+
248
+ The failure path is unchanged and still safe (that generation goes red, the last-good artifact keeps
249
+ serving); the message now names the signal, and calls out `SIGKILL` as most often the OOM killer.
250
+
251
+ - f28466f: fix(server): stop closing the database out from under its own schema setup
252
+
253
+ `getStore()` returns a store synchronously while `ensure()` goes on creating tables and indexes, so a
254
+ shutdown could close the connection mid-setup. The rejection was unhandled — `void store.ensure()` —
255
+ and surfaced as `RangeError: Cannot use a closed database` blamed on whatever ran next, which read as a
256
+ flaky test rather than a race at shutdown. Reproduced at 3 failures in 8 runs of the akanjs suite, 0 in
257
+ 10 after the fix.
258
+
259
+ All three SQL adaptors (bun:sqlite, libsql, Postgres) now track those setups and let them finish before
260
+ closing. Every statement `ensure()` runs is `IF NOT EXISTS`, so one cut short is simply redone next boot.
261
+
262
+ - 51851fa: perf: cut idle/dev-save memory with phase-1 quick wins
263
+
264
+ Apply the phase-1 resource plan without architecture changes:
265
+
266
+ - Self-arming CSR rebuild — skip the dead CSR artifact until `/__csr` or `?csr=true` first needs it
267
+ (keeps mobile live-reload working once armed).
268
+ - Bound RSC worker reload accumulation with threshold/RSS recycle instead of retaining every pages
269
+ bundle generation.
270
+ - Split `@akanjs/devkit` into subpath exports and move route/overrides AST validation out of the
271
+ resident `executors` graph so `typescript` is not pulled into long-lived start processes.
272
+ - Lazy-load CLI command modules via a command manifest so unused command graphs stay cold.
273
+
274
+ Also await async endpoint guards (including in parallel) so `canPass` promises are honored.
275
+
276
+ - 1c3436f: perf: bound builder memory with RSS recycle and disposable batch workers
277
+
278
+ Apply the phase-2 bounded-builder plan so Bun.build retention no longer grows without
279
+ bound across a long `akan start` session:
280
+
281
+ - Report builder RSS after each work item and recycle the builder process when it crosses
282
+ a ceiling (`AKAN_BUILDER_MAX_RSS_MB`, else cgroup × 0.35, else 1200 MB), only when no
283
+ build is in flight and the generation is green.
284
+ - Extract shared `memoryLimit` helpers (also used by the RSC worker) and announce recovered
285
+ pages/css state after a recycle-triggered boot so a live backend picks up the new
286
+ `base-artifact.json`.
287
+ - Move pages/css/csr `Bun.build` work into a disposable `buildBatch` worker that exits per
288
+ generation (optional `AKAN_BUILD_WORKER_REUSE_COUNT`), keeping the watcher process thin.
289
+
290
+ - 128e9a3: fix(devkit): survive a container image with no `ps`
291
+
292
+ `DevStabilityHarness` shells out to `ps` to find leftover dev processes, and slim images such as
293
+ `oven/bun` ship without procps, so the spawn threw. It now returns the same `null` it already
294
+ returns for a `ps` that does not answer in time — "could not look", not "nothing is running".
295
+ Fixture liveness never depended on it (`process.kill(pid, 0)`), so sweeping still works.
296
+
297
+ - a5d4a8a: fix: register and correctly invoke `internal(... { process })` queue workers
298
+
299
+ `process` internals accepted jobs but never ran them. Three defects:
300
+
301
+ - `buildInternal.process` was the only scheduled factory that did not default `enabled: true`, so
302
+ `SignalResolver.resolveSchedule` skipped it and no worker was ever registered. Placement is now governed by
303
+ `serverMode`/`operationMode` alone, matching the existing `serverMode: "all"` default.
304
+ - Registered workers were called with the `AkanJob` rather than the declared `msg` arguments. The job payload is
305
+ now spread onto the declared args and deserialized against their declared types, so the `exec` signature
306
+ `(...msgArgs, job)` holds at runtime.
307
+ - `BullQueue` scoped its worker to queue `<prefix>:<key>` while enqueueing onto queue `<prefix>`, so cluster mode
308
+ never consumed jobs. Producer and consumer now share one queue per process key.
309
+
310
+ `resolveSchedule` also logs when a `process` internal gets no worker on the current server, since the producer is
311
+ installed regardless of placement.
312
+
313
+ - 473be34: fix(devkit): stop turning the builder's memory ceiling off on the first page load
314
+
315
+ The dev host stops enforcing the builder's RSS ceiling when recycling evidently cannot meet it. The
316
+ evidence it used was "two over-ceiling reports within 30s of a recycle" — but a builder reports after
317
+ every build, and one page load builds a route per navigation. On a container-derived ceiling (a 1.2GB
318
+ sandbox gives the builder ~420MB, against ~247MB per route build) the first page load after a recycle
319
+ switched the ceiling off for the rest of the session, leaving the builder unbounded on exactly the
320
+ deployment shape the ceiling exists for.
321
+
322
+ It now measures what it always claimed to: when a replacement builder becomes ready, before it has built
323
+ anything on demand, the host reads its RSS from the OS. That is the floor every future replacement lands
324
+ on, so a floor already over the ceiling means recycling cannot help — and only that stops enforcement,
325
+ with the message naming `AKAN_BUILDER_MAX_RSS_MB`. Otherwise the ceiling stands and the existing 30s
326
+ minimum interval bounds what it costs, now with a one-off warning when the builder keeps crossing back
327
+ inside that interval.
328
+
3
329
  ## 2.4.0
4
330
 
5
331
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.1-rc.7",
3
+ "version": "2.4.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1586,6 +1586,41 @@ export class SqlDocumentStore {
1586
1586
  throw new Error(`Invalid database identifier: ${refName}`);
1587
1587
  }
1588
1588
  }
1589
+
1590
+ /**
1591
+ * The schema setups `getStore` starts but nobody awaits.
1592
+ *
1593
+ * `getStore` returns a store synchronously while `ensure()` goes on creating tables and indexes, so
1594
+ * the connection could be closed out from under one — a server shutting down, or a test tearing its
1595
+ * fixture down. That surfaced as an unhandled `Cannot use a closed database` blamed on whatever ran
1596
+ * next, which made it look like a flaky test rather than a race at shutdown.
1597
+ *
1598
+ * Every statement `ensure()` runs is `IF NOT EXISTS`, so one cut short is simply redone on the next
1599
+ * boot; a failure *before* the close is still a real problem and still surfaces.
1600
+ */
1601
+ class PendingStoreEnsures {
1602
+ readonly #pending = new Set<Promise<void>>();
1603
+ #closed = false;
1604
+
1605
+ track(ensure: Promise<void>): void {
1606
+ const tracked = ensure
1607
+ .catch((error: unknown) => {
1608
+ if (this.#closed) return;
1609
+ throw error;
1610
+ })
1611
+ .finally(() => {
1612
+ this.#pending.delete(tracked);
1613
+ });
1614
+ this.#pending.add(tracked);
1615
+ }
1616
+
1617
+ /** Let them finish against a live connection. Call before closing it. */
1618
+ async settle(): Promise<void> {
1619
+ this.#closed = true;
1620
+ await Promise.allSettled([...this.#pending]);
1621
+ }
1622
+ }
1623
+
1589
1624
  export class SqliteDatabase
1590
1625
  extends adapt("sqliteDatabase", ({ env }) => ({
1591
1626
  config: env((env: SqliteEnv) => {
@@ -1617,6 +1652,7 @@ export class SqliteDatabase
1617
1652
  #client!: BunSqliteClient;
1618
1653
  #stores = new Map<string, SqlDocumentStore>();
1619
1654
  #transaction = new AsyncLocalStorage<TransactionContext>();
1655
+ #ensures = new PendingStoreEnsures();
1620
1656
 
1621
1657
  override async onInit() {
1622
1658
  await mkdir(path.dirname(this.config.filePath), { recursive: true });
@@ -1634,6 +1670,7 @@ export class SqliteDatabase
1634
1670
  }
1635
1671
 
1636
1672
  override async onDestroy() {
1673
+ await this.#ensures.settle();
1637
1674
  this.#db?.run("PRAGMA wal_checkpoint(TRUNCATE)");
1638
1675
  await this.#client?.close();
1639
1676
  }
@@ -1647,7 +1684,7 @@ export class SqliteDatabase
1647
1684
  if (existing) return existing;
1648
1685
  const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema);
1649
1686
  this.#stores.set(database.refName, store);
1650
- void store.ensure();
1687
+ this.#ensures.track(store.ensure());
1651
1688
  return store;
1652
1689
  }
1653
1690
 
@@ -1724,6 +1761,7 @@ export class LibsqlDatabase
1724
1761
  #client!: LibsqlAkanClient;
1725
1762
  #stores = new Map<string, SqlDocumentStore>();
1726
1763
  #transaction = new AsyncLocalStorage<TransactionContext>();
1764
+ #ensures = new PendingStoreEnsures();
1727
1765
 
1728
1766
  override async onInit() {
1729
1767
  const url = this.config.url ?? "file:local.db";
@@ -1736,6 +1774,7 @@ export class LibsqlDatabase
1736
1774
  }
1737
1775
 
1738
1776
  override async onDestroy() {
1777
+ await this.#ensures.settle();
1739
1778
  await this.#client?.close();
1740
1779
  }
1741
1780
 
@@ -1748,7 +1787,7 @@ export class LibsqlDatabase
1748
1787
  if (existing) return existing;
1749
1788
  const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema);
1750
1789
  this.#stores.set(database.refName, store);
1751
- void store.ensure();
1790
+ this.#ensures.track(store.ensure());
1752
1791
  return store;
1753
1792
  }
1754
1793
 
@@ -1808,6 +1847,7 @@ export class PostgresDatabase
1808
1847
  #client!: PostgresAkanClient;
1809
1848
  #stores = new Map<string, SqlDocumentStore>();
1810
1849
  #transaction = new AsyncLocalStorage<TransactionContext>();
1850
+ #ensures = new PendingStoreEnsures();
1811
1851
 
1812
1852
  override async onInit() {
1813
1853
  const { default: postgres } = await import("postgres");
@@ -1827,6 +1867,7 @@ export class PostgresDatabase
1827
1867
  }
1828
1868
 
1829
1869
  override async onDestroy() {
1870
+ await this.#ensures.settle();
1830
1871
  await this.#client?.close();
1831
1872
  }
1832
1873
 
@@ -1839,7 +1880,7 @@ export class PostgresDatabase
1839
1880
  if (existing) return existing;
1840
1881
  const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema, new PostgresDialect());
1841
1882
  this.#stores.set(database.refName, store);
1842
- void store.ensure();
1883
+ this.#ensures.track(store.ensure());
1843
1884
  return store;
1844
1885
  }
1845
1886