toiljs 0.0.112 → 0.0.114

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 (46) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +10 -0
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +145 -27
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/compiler/.tsbuildinfo +1 -1
  8. package/build/compiler/index.js +1 -1
  9. package/build/compiler/pages.js +1 -13
  10. package/build/compiler/prerender.d.ts +1 -0
  11. package/build/compiler/prerender.js +39 -2
  12. package/build/compiler/toil-docs.generated.js +4 -4
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.d.ts +2 -0
  15. package/build/devserver/analytics/index.js +15 -9
  16. package/build/devserver/daemon/host.d.ts +2 -1
  17. package/build/devserver/daemon/host.js +12 -12
  18. package/build/devserver/daemon/index.js +3 -3
  19. package/build/io/.tsbuildinfo +1 -1
  20. package/build/logger/.tsbuildinfo +1 -1
  21. package/build/shared/.tsbuildinfo +1 -1
  22. package/docs/background/daemons.md +49 -3
  23. package/docs/cli/README.md +4 -2
  24. package/docs/getting-started/installation.md +18 -0
  25. package/docs/services/analytics.md +28 -17
  26. package/examples/basic/server/routes/Analytics.ts +6 -5
  27. package/package.json +15 -15
  28. package/src/cli/create.ts +1 -0
  29. package/src/cli/diagnostics.ts +74 -0
  30. package/src/cli/doctor.ts +105 -27
  31. package/src/cli/update.ts +30 -3
  32. package/src/cli/updates.ts +23 -0
  33. package/src/compiler/index.ts +11 -2
  34. package/src/compiler/pages.ts +1 -22
  35. package/src/compiler/prerender.ts +57 -3
  36. package/src/compiler/toil-docs.generated.ts +4 -4
  37. package/src/devserver/analytics/index.ts +40 -17
  38. package/src/devserver/daemon/host.ts +29 -19
  39. package/src/devserver/daemon/index.ts +12 -5
  40. package/test/analytics-dev.test.ts +8 -8
  41. package/test/daemon-emulation.test.ts +44 -2
  42. package/test/doctor.test.ts +28 -0
  43. package/test/fixtures/daemon-app.ts +9 -4
  44. package/test/prerender.test.ts +64 -2
  45. package/test/update.test.ts +15 -1
  46. package/tsconfig.base.json +0 -1
@@ -15,8 +15,19 @@ import type { DbDevState } from '../db/index.js';
15
15
  import type { MemoryRef } from '../runtime/host.js';
16
16
 
17
17
  /** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
18
- const FRAME_VERSION = 2;
18
+ const FRAME_VERSION = 3;
19
19
  const METRIC_COUNTERS = 42;
20
+
21
+ /** The sustained-rate window: requests are metered over a 3-minute ROLLING window. A plan's
22
+ * `sustained_rps` (X) allows `X * BURST_WINDOW_SECS` requests across it; instantaneous spikes above
23
+ * that are NOT capped per-tier (only the box `firewall.global_pps` DDoS backstop applies). */
24
+ export const BURST_WINDOW_SECS = 180;
25
+
26
+ /** The plan's sustained rate in requests/second, derived from the rolling window's allowance.
27
+ * `0` means unmetered (no sustained cap), and must NOT be divided into a rate. */
28
+ export function sustainedRpsCap(reqBurstCap: number): number {
29
+ return reqBurstCap === 0 ? 0 : reqBurstCap / BURST_WINDOW_SECS;
30
+ }
20
31
  /** MetricId indices we seed with sample data (others default 0). Mirrors the AUTHORITATIVE edge wire
21
32
  * contract EXACTLY (`toil-backend/src/analytics/metric_id.rs`): counter ids 0..=41, gauge ids
22
33
  * ConnectedStreamsAvg=42, CommittedMemoryAvg=44. There is NO WasmDispatches (removed + reindexed); the
@@ -43,10 +54,16 @@ interface DevTenantStats {
43
54
  life: bigint[]; // length METRIC_COUNTERS, indexed by MetricId
44
55
  connectedStreams: number;
45
56
  committedMemory: number;
46
- reqMinuteUsed: number;
47
- reqMinuteCap: number;
48
- reqDayUsed: number;
49
- reqDayCap: number;
57
+ // Fleet-global requests in the trailing 3-minute ROLLING window, and its allowance
58
+ // (= sustained_rps * BURST_WINDOW_SECS; 0 = unmetered). Spikes are not tier-capped.
59
+ reqBurstUsed: number;
60
+ reqBurstCap: number;
61
+ // Fleet-global requests in the current 30-day tumbling bucket, and the 30-day quota (0 = unmetered).
62
+ req30dUsed: number;
63
+ req30dCap: number;
64
+ // Derived (NOT part of the wire frame): the plan's sustained request rate, `reqBurstCap /
65
+ // BURST_WINDOW_SECS` (0 = unmetered). Surfaced so a dev dashboard can show "X sustained rps".
66
+ sustainedRpsCap: number;
50
67
  // LIVE per-second rates (f64), appended after the windows. Mirrors the edge `encode_stats`.
51
68
  rps: number;
52
69
  bytesInPerSec: number;
@@ -76,14 +93,18 @@ function devStats(): DevTenantStats {
76
93
  life[MID.MemGrownBytes] = 262144n;
77
94
  life[MID.CacheHits] = 900n;
78
95
  life[MID.CacheMisses] = 100n;
96
+ // Free tier: sustained 1000 rps -> a 3-minute burst cap of 1000 * 180 = 180_000, plus a 30-day
97
+ // quota of 10_000_000. `used` values are small so the dashboard shows plenty of headroom.
98
+ const reqBurstCap: number = 180_000;
79
99
  return {
80
100
  life,
81
101
  connectedStreams: 3,
82
102
  committedMemory: 65536,
83
- reqMinuteUsed: 5,
84
- reqMinuteCap: 100,
85
- reqDayUsed: 42,
86
- reqDayCap: 5000,
103
+ reqBurstUsed: 1_200,
104
+ reqBurstCap,
105
+ req30dUsed: 250_000,
106
+ req30dCap: 10_000_000,
107
+ sustainedRpsCap: sustainedRpsCap(reqBurstCap),
87
108
  // Plausible non-zero live rates so a dev dashboard shows moving gauges.
88
109
  rps: 0.7,
89
110
  bytesInPerSec: 68.2,
@@ -97,10 +118,12 @@ function devStats(): DevTenantStats {
97
118
  }
98
119
 
99
120
  /**
100
- * Encode the v2 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
101
- * layout (no strings). Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
121
+ * Encode the v3 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
122
+ * layout (no strings). The v2 -> v3 bump renamed two u64 pairs (req_minute_* -> req_burst_*,
123
+ * req_day_* -> req_30d_*); the BYTE LAYOUT is UNCHANGED (same field count, order, and widths).
124
+ * Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
102
125
  * u16 version | u64 now_ms | u32 count | u64 × count | u64 connectedStreams | u64 committedMemory |
103
- * u64 reqMinuteUsed | u64 reqMinuteCap | u64 reqDayUsed | u64 reqDayCap |
126
+ * u64 reqBurstUsed | u64 reqBurstCap | u64 req30dUsed | u64 req30dCap |
104
127
  * f64 rps | f64 bytesInPerSec | f64 bytesOutPerSec | f64 streamBytesInPerSec |
105
128
  * f64 streamBytesOutPerSec | f64 dbOpsPerSec | f64 gasPerSec
106
129
  * The rates are APPENDED (version-gated by length): the guest's bounds-checked reader yields 0.0 for a
@@ -120,10 +143,10 @@ function encodeStats(s: DevTenantStats): Buffer {
120
143
  }
121
144
  body.writeBigUInt64LE(BigInt(s.connectedStreams), o); o += 8;
122
145
  body.writeBigUInt64LE(BigInt(s.committedMemory), o); o += 8;
123
- body.writeBigUInt64LE(BigInt(s.reqMinuteUsed), o); o += 8;
124
- body.writeBigUInt64LE(BigInt(s.reqMinuteCap), o); o += 8;
125
- body.writeBigUInt64LE(BigInt(s.reqDayUsed), o); o += 8;
126
- body.writeBigUInt64LE(BigInt(s.reqDayCap), o); o += 8;
146
+ body.writeBigUInt64LE(BigInt(s.reqBurstUsed), o); o += 8;
147
+ body.writeBigUInt64LE(BigInt(s.reqBurstCap), o); o += 8;
148
+ body.writeBigUInt64LE(BigInt(s.req30dUsed), o); o += 8;
149
+ body.writeBigUInt64LE(BigInt(s.req30dCap), o); o += 8;
127
150
  body.writeDoubleLE(s.rps, o); o += 8;
128
151
  body.writeDoubleLE(s.bytesInPerSec, o); o += 8;
129
152
  body.writeDoubleLE(s.bytesOutPerSec, o); o += 8;
@@ -165,7 +188,7 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
165
188
  }
166
189
 
167
190
  /**
168
- * Encode the v2 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
191
+ * Encode the v3 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
169
192
  * u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
170
193
  * A synthetic gentle ramp so a dev dashboard draws a non-flat line.
171
194
  */
@@ -10,6 +10,12 @@
10
10
  * - Part 3 error bridge: a u16 subsystem code `c` is returned as `-(0x10000 + c)`;
11
11
  * the buffer sentinels `-1` (TOO_SMALL) / `-2` (ABSENT) are unchanged.
12
12
  *
13
+ * These live in their OWN wasm module namespace, `daemon`, with BARE names, exactly
14
+ * as the edge registers them (toil-backend `src/wasm/cold/imports.rs`, `NS_DAEMON`).
15
+ * They are NOT dotted names under `env` like `data.*` / `crypto.*` are: a cold box
16
+ * that declares them that way resolves here and then trap-stubs at the edge, which
17
+ * is the one shape of drift a dev emulator must never hide.
18
+ *
13
19
  * The cold box also imports the request-surface `env.*` MINUS the response/stream
14
20
  * functions it must not have (no `set_status`/`set_header`/`respond_file`/
15
21
  * `client_ip`/`ratelimit_check`); it keeps `@data`/crypto/env/`Date.now`/email so a
@@ -38,10 +44,14 @@ export interface ResolvedDaemonConfig {
38
44
 
39
45
  /** RECONCILIATION Part 3 u16 error registry (the subset the daemon imports use). */
40
46
  export const enum AbiError {
41
- DaemonScheduleRejected = 0x0403,
47
+ DaemonNotLeader = 0x0401,
48
+ DaemonLeaseLost = 0x0402,
42
49
  DaemonCallFailed = 0x0405,
43
50
  }
44
51
 
52
+ /** The edge's `EPOCH_NONE` / "no such task" sentinel: a plain -1, not a bridged error. */
53
+ const DAEMON_NONE = -1n;
54
+
45
55
  /** Encode a u16 subsystem error per the Part 3 negative-return bridge:
46
56
  * `code = (-v) - 0x10000`, so `v = -(0x10000 + code)`. */
47
57
  export function encodeAbiError(code: number): number {
@@ -64,26 +74,27 @@ export interface DaemonRuntime {
64
74
  * `number | bigint` individually, matching the existing db/crypto import maps). */
65
75
  type HostFnMap = Record<string, (...args: number[]) => number | bigint>;
66
76
 
67
- /** Build the `daemon.*` host imports, closing over the resident `DaemonRuntime`.
68
- * These imports do not read guest memory (they answer from the resident scheduler
69
- * state), so they take no `MemoryRef`. */
77
+ /** Build the `daemon` namespace, closing over the resident `DaemonRuntime`. The names
78
+ * are BARE (`is_leader`, not `daemon.is_leader`): they are keyed under the `daemon`
79
+ * wasm module, matching the edge. These imports do not read guest memory (they answer
80
+ * from the resident scheduler state), so they take no `MemoryRef`. */
70
81
  export function buildDaemonNamespace(rt: DaemonRuntime): HostFnMap {
71
82
  return {
72
- 'daemon.is_leader': (): number => (rt.isLeader() ? 1 : 0),
73
- 'daemon.current_epoch': (): bigint => rt.epoch(),
83
+ is_leader: (): number => (rt.isLeader() ? 1 : 0),
84
+ // The edge answers -1 (EPOCH_NONE) when this node does not hold the lease.
85
+ current_epoch: (): bigint => (rt.isLeader() ? rt.epoch() : DAEMON_NONE),
74
86
  // The dev lease never expires, so yield/sleep never report LEASE_LOST.
75
- 'daemon.yield': (): number => 0,
76
- 'daemon.sleep_ms': (_ms: number | bigint): number => 0,
77
- 'daemon.task_count': (): number => rt.taskCount(),
78
- 'daemon.next_fire_ms': (taskId: number): bigint => {
87
+ yield: (): number => 0,
88
+ sleep_ms: (_ms: number | bigint): number => 0,
89
+ task_count: (): number => rt.taskCount(),
90
+ // An unknown / never-firing task is -1 at the edge, NOT a bridged error.
91
+ next_fire_ms: (taskId: number): bigint => {
79
92
  const at = rt.nextFireMs(taskId);
80
- return at === null
81
- ? BigInt(encodeAbiError(AbiError.DaemonScheduleRejected))
82
- : BigInt(at);
93
+ return at === null ? DAEMON_NONE : BigInt(at);
83
94
  },
84
95
  // Outbound HTTP call stub: dev returns a "call failed" sentinel rather than
85
96
  // performing real network I/O from a synchronous wasm import (section 5.4).
86
- 'daemon.http_call': (
97
+ http_call: (
87
98
  _reqPtr: number,
88
99
  _reqLen: number,
89
100
  _outPtr: number,
@@ -103,10 +114,9 @@ export function freshDaemonState(): DaemonState {
103
114
  }
104
115
 
105
116
  /**
106
- * The full `env` import object for the cold daemon box: the request-surface env
107
- * MINUS the response/stream functions (built by `buildEnvImports`), PLUS the
108
- * `daemon.*` namespace. The cold box has no `handle` entry and no response
109
- * surface.
117
+ * The import object for the cold daemon box: the request-surface `env` MINUS the
118
+ * response/stream functions (built by `buildEnvImports`), plus the separate
119
+ * `daemon` module. The cold box has no `handle` entry and no response surface.
110
120
  */
111
121
  export function buildDaemonImports(
112
122
  ref: MemoryRef,
@@ -118,7 +128,7 @@ export function buildDaemonImports(
118
128
  ...buildEnvImports(ref, state),
119
129
  ...buildCryptoImports(ref, state.crypto),
120
130
  ...buildDatabaseImports(ref, state.db),
121
- ...buildDaemonNamespace(rt),
122
131
  },
132
+ daemon: buildDaemonNamespace(rt),
123
133
  };
124
134
  }
@@ -195,12 +195,19 @@ export class DaemonHost implements DaemonRuntime {
195
195
  const imports = buildDaemonImports(ref, this.state, this);
196
196
 
197
197
  // Fail-closed up front, with names, when the cold box imports anything outside its allowed
198
- // surface (the request env subset + crypto + @data + daemon.*; NO response/stream
199
- // functions). Mirrors `WasmServerModule.assertImportSurface` (section 7.1).
200
- const provided = new Set(Object.keys((imports as { env: Record<string, unknown> }).env));
198
+ // surface (the request env subset + crypto + @data, and the separate `daemon` module; NO
199
+ // response/stream functions). Mirrors `WasmServerModule.assertImportSurface` (section 7.1).
200
+ // Both namespaces are checked: `daemon.*` is its own wasm module at the edge, so a guest
201
+ // that declares those names under `env` must fail HERE rather than trap only in production.
202
+ // A non-function import is rejected outright, as the edge does (`NonFunctionImport`):
203
+ // no memory/global/table is ever registered, so leaving one to fail later would
204
+ // surface as an opaque LinkError rather than a named, fail-closed diagnostic.
205
+ const provided = imports as Record<string, Record<string, unknown>>;
201
206
  const missing = WebAssembly.Module.imports(this.module)
202
- .filter((i) => i.kind === 'function' && (i.module !== 'env' || !provided.has(i.name)))
203
- .map((i) => `${i.module}.${i.name}`);
207
+ .filter(
208
+ (i) => i.kind !== 'function' || !Object.hasOwn(provided[i.module] ?? {}, i.name),
209
+ )
210
+ .map((i) => `${i.module}.${i.name}${i.kind === 'function' ? '' : ` (${i.kind})`}`);
204
211
  if (missing.length > 0) {
205
212
  this.log(
206
213
  pc.red(
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Dev-server analytics stub parity: the `env.analytics_read` / `env.analytics_series` dev imports must
3
- * encode the v2 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
3
+ * encode the v3 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
4
4
  * decode is identical in dev and prod. We decode the stashed frame positionally, exactly as the guest.
5
5
  */
6
6
  import { describe, expect, it } from 'vitest';
@@ -16,7 +16,7 @@ function harness() {
16
16
  return { imports: buildAnalyticsImports(ref, db), db };
17
17
  }
18
18
 
19
- describe('dev analytics stub v2 frame parity', () => {
19
+ describe('dev analytics stub v3 frame parity', () => {
20
20
  it('analytics_read encodes the fixed-layout snapshot frame', () => {
21
21
  const { imports, db } = harness();
22
22
  const n = imports.analytics_read(0, 0);
@@ -29,7 +29,7 @@ describe('dev analytics stub v2 frame parity', () => {
29
29
  const u64 = () => { const v = buf.readBigUInt64LE(p); p += 8; return v; };
30
30
  const f64 = () => { const v = buf.readDoubleLE(p); p += 8; return v; };
31
31
 
32
- expect(u16()).toBe(2); // FRAME_VERSION
32
+ expect(u16()).toBe(3); // FRAME_VERSION
33
33
  u64(); // now_ms
34
34
  const count = u32();
35
35
  expect(count).toBe(42); // METRIC_COUNTERS (edge N_COUNTERS; no WasmDispatches)
@@ -44,10 +44,10 @@ describe('dev analytics stub v2 frame parity', () => {
44
44
  expect(life[41]).toBe(100n); // CacheMisses (id 41, was 42)
45
45
  expect(u64()).toBe(3n); // connectedStreams
46
46
  expect(u64()).toBe(65536n); // committedMemory
47
- expect(u64()).toBe(5n); // reqMinuteUsed
48
- expect(u64()).toBe(100n); // reqMinuteCap
49
- expect(u64()).toBe(42n); // reqDayUsed
50
- expect(u64()).toBe(5000n); // reqDayCap
47
+ expect(u64()).toBe(1_200n); // reqBurstUsed (current 3-min tumbling bucket)
48
+ expect(u64()).toBe(180_000n); // reqBurstCap (sustained 1000 rps * 180s window)
49
+ expect(u64()).toBe(250_000n); // req30dUsed (current 30-day bucket)
50
+ expect(u64()).toBe(10_000_000n); // req30dCap (30-day quota)
51
51
  // 7 LIVE per-second rates (f64 LE), appended after the windows.
52
52
  expect(f64()).toBeCloseTo(0.7); // rps
53
53
  expect(f64()).toBeCloseTo(68.2); // bytesInPerSec
@@ -66,7 +66,7 @@ describe('dev analytics stub v2 frame parity', () => {
66
66
  const buf = db.lastResult as unknown as Buffer;
67
67
  expect(n).toBe(buf.length);
68
68
  let p = 0;
69
- expect(buf.readUInt16LE(p)).toBe(2); p += 2; // version
69
+ expect(buf.readUInt16LE(p)).toBe(3); p += 2; // version
70
70
  expect(buf.readUInt16LE(p)).toBe(0); p += 2; // metric id
71
71
  expect(buf.readUInt32LE(p)).toBe(3600); p += 4; // bucketSecs
72
72
  p += 8; // headMs
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
19
- import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
@@ -24,7 +24,11 @@ import { fileURLToPath } from 'node:url';
24
24
  import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
25
25
 
26
26
  import { DaemonHost } from '../src/devserver/daemon/index.js';
27
- import type { ResolvedDaemonConfig } from '../src/devserver/daemon/host.js';
27
+ import {
28
+ buildDaemonImports,
29
+ freshDaemonState,
30
+ type ResolvedDaemonConfig,
31
+ } from '../src/devserver/daemon/host.js';
28
32
 
29
33
  const here = dirname(fileURLToPath(import.meta.url));
30
34
  const FIXTURE = join(here, 'fixtures', 'daemon-app.ts');
@@ -91,6 +95,44 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
91
95
  expect(toilscriptAvailable, 'cold compile of the @daemon fixture failed').toBe(true);
92
96
  });
93
97
 
98
+ // The edge registers these in their OWN wasm module (`NS_DAEMON`), not as dotted
99
+ // names under `env` the way `data.*` and `crypto.*` are. A cold box that gets it
100
+ // wrong resolves against this emulator and then trap-stubs in production, so pin
101
+ // the namespace on both sides: the guest's declared imports, and what we provide.
102
+ it('declares its daemon imports in the `daemon` module with bare names', () => {
103
+ const module = new WebAssembly.Module(readFileSync(coldWasm));
104
+ const daemonImports = WebAssembly.Module.imports(module)
105
+ .filter((i) => i.module === 'daemon')
106
+ .map((i) => i.name)
107
+ .sort();
108
+ expect(daemonImports).toEqual(['current_epoch', 'is_leader', 'task_count']);
109
+ expect(
110
+ WebAssembly.Module.imports(module).filter((i) => i.name.startsWith('daemon.')),
111
+ ).toEqual([]);
112
+ });
113
+
114
+ it('provides the daemon host functions under the `daemon` namespace', () => {
115
+ const imports = buildDaemonImports({ memory: null }, freshDaemonState(), {
116
+ isLeader: () => true,
117
+ epoch: () => 1n,
118
+ taskCount: () => 0,
119
+ nextFireMs: () => null,
120
+ }) as unknown as Record<string, Record<string, unknown>>;
121
+ expect(Object.keys(imports).sort()).toEqual(['daemon', 'env']);
122
+ expect(Object.keys(imports.daemon).sort()).toEqual([
123
+ 'current_epoch',
124
+ 'http_call',
125
+ 'is_leader',
126
+ 'next_fire_ms',
127
+ 'sleep_ms',
128
+ 'task_count',
129
+ 'yield',
130
+ ]);
131
+ expect(Object.keys(imports.env).some((k) => k.startsWith('daemon.'))).toBe(false);
132
+ // An unknown task is the edge's plain -1 sentinel, not a bridged error code.
133
+ expect((imports.daemon.next_fire_ms as (id: number) => bigint)(9)).toBe(-1n);
134
+ });
135
+
94
136
  it('runs daemon_start exactly once on load', () => {
95
137
  const host = new DaemonHost(coldWasm, DAEMON_CFG, 'all', () => {});
96
138
  host.refresh();
@@ -18,7 +18,9 @@ import {
18
18
  checkSeoUrl,
19
19
  checkServerTsPlugin,
20
20
  checkStyling,
21
+ checkTypeScript,
21
22
  findRelativeAssets,
23
+ rangeMajor,
22
24
  satisfiesMin,
23
25
  summarize,
24
26
  } from '../src/cli/diagnostics';
@@ -91,6 +93,32 @@ describe('config + environment checks', () => {
91
93
  expect(checkPeer('react', '^19.0.0', '>=18.0.0').status).toBe('pass');
92
94
  });
93
95
 
96
+ it('checkTypeScript fails an installed 7.x (the native port, no compiler API)', () => {
97
+ const check = checkTypeScript('7.0.2', '^7.0.2');
98
+ expect(check.status).toBe('fail');
99
+ expect(check.detail).toContain('7.0.2');
100
+ expect(check.fix).toContain('^6.0.3');
101
+ });
102
+
103
+ it('checkTypeScript fails a declared 7.x even while a supported copy is installed', () => {
104
+ // The installed copy still works, but the next install would pull the native port.
105
+ expect(checkTypeScript('6.0.3', '^7.0.0').status).toBe('fail');
106
+ expect(checkTypeScript('6.0.3', '>=7').status).toBe('fail');
107
+ });
108
+
109
+ it('checkTypeScript passes a supported 6.x and warns below the floor', () => {
110
+ expect(checkTypeScript('6.0.3', '^6.0.3').status).toBe('pass');
111
+ expect(checkTypeScript('5.9.3', '^5.9.0').status).toBe('warn');
112
+ expect(checkTypeScript(null, null).status).toBe('fail');
113
+ });
114
+
115
+ it('rangeMajor reads the major a range floats to, ignoring comparators', () => {
116
+ expect(rangeMajor('^7.0.2')).toBe(7);
117
+ expect(rangeMajor('>=6.0.0 <7.0.0')).toBe(6);
118
+ expect(rangeMajor('~6.0')).toBe(6);
119
+ expect(rangeMajor('*')).toBe(null);
120
+ });
121
+
94
122
  it('checkDuplicatePatterns flags repeated route URLs', () => {
95
123
  expect(checkDuplicatePatterns(['/', '/about', '/blog/:id']).status).toBe('pass');
96
124
  expect(checkDuplicatePatterns(['/a', '/a']).status).toBe('warn');
@@ -1,21 +1,26 @@
1
1
  // A minimal @daemon fixture for the dev daemon-emulation test. It declares the
2
- // `daemon.*` host imports directly (so it needs no toiljs globals lib) and records
2
+ // daemon host imports directly (so it needs no toiljs globals lib) and records
3
3
  // its activity into resident daemon wasm memory. Compiled with `--targetMode cold`
4
4
  // by the test.
5
5
  //
6
6
  // onStart() -> increments `started` and stamps the lease epoch
7
7
  // tick() @scheduled -> increments `tickFast` (1s interval)
8
8
  // sixHourly() cron -> increments `tickCron` (0 */6 * * *)
9
+ //
10
+ // The imports live in the `daemon` wasm module with BARE names, as the edge
11
+ // registers them; they are not dotted names under `env`. toilscript's stdlib
12
+ // exposes the same surface as the ambient `Daemon` class (`~lib/daemon`), which
13
+ // real daemons should use instead of hand-declaring these.
9
14
 
10
15
  // @ts-nocheck — this is AssemblyScript source compiled by toilscript, not TS.
11
16
 
12
- @external("env", "daemon.is_leader")
17
+ @external("daemon", "is_leader")
13
18
  declare function daemonIsLeader(): i32;
14
19
 
15
- @external("env", "daemon.current_epoch")
20
+ @external("daemon", "current_epoch")
16
21
  declare function daemonCurrentEpoch(): i64;
17
22
 
18
- @external("env", "daemon.task_count")
23
+ @external("daemon", "task_count")
19
24
  declare function daemonTaskCount(): i32;
20
25
 
21
26
  let started: i32 = 0;
@@ -3,9 +3,13 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
5
  import ts from 'typescript';
6
- import { afterEach, describe, expect, it } from 'vitest';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
7
 
8
- import { extractStaticMetadata } from '../src/compiler/prerender';
8
+ import {
9
+ extractStaticMetadata,
10
+ loadTypeScript,
11
+ loadTypeScriptSync,
12
+ } from '../src/compiler/prerender';
9
13
 
10
14
  const written: string[] = [];
11
15
  function tmp(source: string): string {
@@ -52,3 +56,61 @@ describe('extractStaticMetadata', () => {
52
56
  expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
53
57
  });
54
58
  });
59
+
60
+ /**
61
+ * Builds a throwaway project root whose resolvable `typescript` is `main`-ed at a module exporting
62
+ * only `version` — the shape TypeScript 7 ships, having moved the compiler API to
63
+ * `typescript/unstable/*`. Resolution succeeds, so a truthy check can't tell it apart from a usable
64
+ * compiler; only probing the API can.
65
+ */
66
+ function rootWithApiLessTypeScript(): string {
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `toil-ts7-${process.pid}-`));
68
+ dirs.push(root);
69
+ const pkgDir = path.join(root, 'node_modules', 'typescript');
70
+ fs.mkdirSync(pkgDir, { recursive: true });
71
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}');
72
+ fs.writeFileSync(
73
+ path.join(pkgDir, 'package.json'),
74
+ '{"name":"typescript","version":"7.0.2","main":"./version.cjs"}',
75
+ );
76
+ fs.writeFileSync(
77
+ path.join(pkgDir, 'version.cjs'),
78
+ `module.exports = { version: '7.0.2', versionMajorMinor: '7.0' };\n`,
79
+ );
80
+ return root;
81
+ }
82
+
83
+ const dirs: string[] = [];
84
+ afterEach(() => {
85
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
86
+ });
87
+
88
+ describe('loadTypeScript', () => {
89
+ it('returns null (not a half-built module) when typescript exposes no compiler API', async () => {
90
+ const root = rootWithApiLessTypeScript();
91
+ const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
92
+ try {
93
+ // Both must degrade rather than hand back `{ version }`, whose first `ts.ScriptTarget`
94
+ // read would throw `Cannot read properties of undefined (reading 'Latest')` mid-build.
95
+ expect(loadTypeScriptSync(root)).toBeNull();
96
+ await expect(loadTypeScript(root)).resolves.toBeNull();
97
+ } finally {
98
+ warn.mockRestore();
99
+ }
100
+ });
101
+
102
+ it('returns null when typescript is not installed at all', async () => {
103
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `toil-nots-${process.pid}-`));
104
+ dirs.push(root);
105
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}');
106
+ expect(loadTypeScriptSync(root)).toBeNull();
107
+ await expect(loadTypeScript(root)).resolves.toBeNull();
108
+ });
109
+
110
+ it('returns the compiler API when a usable typescript is installed', async () => {
111
+ // This repo's own root resolves its devDependency typescript, which has the classic API.
112
+ const root = path.resolve(__dirname, '..');
113
+ expect(loadTypeScriptSync(root)?.createSourceFile).toBeTypeOf('function');
114
+ expect((await loadTypeScript(root))?.ScriptTarget.Latest).toBeTypeOf('number');
115
+ });
116
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { buildRows, classifyBump, parseNcuJson } from '../src/cli/updates';
3
+ import { buildRows, classifyBump, parseNcuJson, withheldUpgrades } from '../src/cli/updates';
4
4
 
5
5
  describe('classifyBump', () => {
6
6
  it('classifies major / minor / patch from ranges', () => {
@@ -42,3 +42,17 @@ describe('buildRows', () => {
42
42
  expect(rows[0]).toMatchObject({ name: 'foo', from: '?', to: '^2.0.0', bump: 'major' });
43
43
  });
44
44
  });
45
+
46
+ describe('withheldUpgrades', () => {
47
+ it('withholds an upgrade into typescript 7 (the native port has no compiler API)', () => {
48
+ expect(withheldUpgrades({ typescript: '^7.0.2', react: '^20.0.0' })).toEqual(['typescript']);
49
+ });
50
+
51
+ it('still offers typescript bumps inside the supported major', () => {
52
+ expect(withheldUpgrades({ typescript: '^6.1.0' })).toEqual([]);
53
+ });
54
+
55
+ it('ignores packages with no declared ceiling', () => {
56
+ expect(withheldUpgrades({ vite: '^9.0.0', eslint: '^11.0.0' })).toEqual([]);
57
+ });
58
+ });
@@ -3,7 +3,6 @@
3
3
  "declaration": true,
4
4
  "noImplicitAny": true,
5
5
  "removeComments": true,
6
- "suppressImplicitAnyIndexErrors": false,
7
6
  "preserveConstEnums": true,
8
7
  "resolveJsonModule": true,
9
8
  "skipLibCheck": true,