@arcote.tech/arc-cli 0.7.4 → 0.7.6

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.
@@ -22,11 +22,24 @@ export interface DeployTarget {
22
22
  sshKey?: string;
23
23
  }
24
24
 
25
+ /**
26
+ * Database choice per environment. Omit to keep the historical default
27
+ * (SQLite in a named volume at `/app/.arc/data`). Selecting `postgres`
28
+ * makes `arc platform deploy` provision a `postgres:16-alpine` sidecar
29
+ * service on the docker network, persist its data in
30
+ * `arc-pgdata-<envName>`, and inject `DATABASE_URL` into the arc container.
31
+ */
32
+ export type DeployEnvDb =
33
+ | { type: "sqlite" }
34
+ | { type: "postgres"; image?: string };
35
+
25
36
  export interface DeployEnv {
26
37
  /** Subdomain or full domain routed by Caddy. */
27
38
  domain: string;
28
39
  /** Extra env vars passed to the arc container. */
29
40
  envVars?: Record<string, string>;
41
+ /** Optional storage backend selector. Default = sqlite. */
42
+ db?: DeployEnvDb;
30
43
  }
31
44
 
32
45
  export interface DeployCaddy {
@@ -55,6 +68,35 @@ export interface DeployProvision {
55
68
  ansible?: DeployProvisionAnsible;
56
69
  }
57
70
 
71
+ /**
72
+ * Optional observability stack (otel-collector + Tempo + Loki + Prometheus
73
+ * + Grafana). Stack-wide — applies to every env on the same VPS, accessed
74
+ * through a single Grafana UI at `<subdomain>.<apex-of-app-domain>`.
75
+ *
76
+ * When `enabled: true`, every arc-<env> container gets:
77
+ * - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
78
+ * - OTEL_SERVICE_NAME=arc-<env>
79
+ * - ARC_OTEL_ENABLED=true
80
+ *
81
+ * Sidecar containers come up on the same `arc-net`. Grafana password is
82
+ * stored in `deploy.arc.env` under `adminPasswordEnv`; first deploy auto-
83
+ * generates one if missing.
84
+ */
85
+ export interface DeployObservability {
86
+ enabled: boolean;
87
+ /** Subdomain under the apex of the first env's domain. Default "observability". */
88
+ subdomain?: string;
89
+ /** Env var name (in `deploy.arc.env`) that holds the Grafana admin password.
90
+ * Default "ARC_OBSERVABILITY_PASSWORD". CLI auto-generates one when absent. */
91
+ adminPasswordEnv?: string;
92
+ /** Optional retention overrides. Defaults: traces 7d, logs 7d, metrics 30d. */
93
+ retention?: {
94
+ traces?: string;
95
+ logs?: string;
96
+ metrics?: string;
97
+ };
98
+ }
99
+
58
100
  export interface DeployRegistry {
59
101
  /** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
60
102
  domain: string;
@@ -70,6 +112,7 @@ export interface DeployConfig {
70
112
  caddy: DeployCaddy;
71
113
  registry: DeployRegistry;
72
114
  provision?: DeployProvision;
115
+ observability?: DeployObservability;
73
116
  }
74
117
 
75
118
  export const DEPLOY_CONFIG_FILE = "deploy.arc.json";
@@ -247,7 +290,50 @@ export function validateDeployConfig(input: unknown): DeployConfig {
247
290
  envVars[k] = v;
248
291
  }
249
292
  }
250
- validated.envs[name] = { domain, envVars };
293
+ const dbRaw = (env as Record<string, unknown>).db;
294
+ let db: DeployEnvDb | undefined;
295
+ if (dbRaw !== undefined) {
296
+ if (!isObject(dbRaw)) throw cfgErr(`envs.${name}.db`, "object");
297
+ const dbType = requireString(dbRaw, `envs.${name}.db.type`);
298
+ if (dbType === "sqlite") {
299
+ db = { type: "sqlite" };
300
+ } else if (dbType === "postgres") {
301
+ db = {
302
+ type: "postgres",
303
+ image: optionalString(dbRaw, `envs.${name}.db.image`),
304
+ };
305
+ } else {
306
+ throw new Error(
307
+ `deploy.arc.json: envs.${name}.db.type must be "sqlite" or "postgres" (got "${dbType}")`,
308
+ );
309
+ }
310
+ }
311
+ validated.envs[name] = { domain, envVars, db };
312
+ }
313
+
314
+ const observabilityRaw = (input as Record<string, unknown>).observability;
315
+ if (observabilityRaw !== undefined) {
316
+ if (!isObject(observabilityRaw)) throw cfgErr("observability", "object");
317
+ const enabledRaw = observabilityRaw.enabled;
318
+ if (typeof enabledRaw !== "boolean") throw cfgErr("observability.enabled", "boolean");
319
+ const retentionRaw = observabilityRaw.retention;
320
+ let retention: DeployObservability["retention"];
321
+ if (retentionRaw !== undefined) {
322
+ if (!isObject(retentionRaw)) throw cfgErr("observability.retention", "object");
323
+ retention = {
324
+ traces: optionalString(retentionRaw, "observability.retention.traces"),
325
+ logs: optionalString(retentionRaw, "observability.retention.logs"),
326
+ metrics: optionalString(retentionRaw, "observability.retention.metrics"),
327
+ };
328
+ }
329
+ validated.observability = {
330
+ enabled: enabledRaw,
331
+ subdomain: optionalString(observabilityRaw, "observability.subdomain") ?? "observability",
332
+ adminPasswordEnv:
333
+ optionalString(observabilityRaw, "observability.adminPasswordEnv") ??
334
+ "ARC_OBSERVABILITY_PASSWORD",
335
+ retention,
336
+ };
251
337
  }
252
338
 
253
339
  const provision = (input as Record<string, unknown>).provision;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { appendFileSync, existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
4
  // ---------------------------------------------------------------------------
@@ -57,6 +57,46 @@ export function applyDeployGlobals(globals: Record<string, string>): void {
57
57
  }
58
58
  }
59
59
 
60
+ /**
61
+ * Ensure a value exists for `key` in a `deploy.arc.*.env` file. If absent,
62
+ * generate one via `generate()`, append it to the file (creating it if
63
+ * needed), and return the value. Pre-existing values (from the file or
64
+ * process.env) are returned unchanged.
65
+ *
66
+ * `scope`:
67
+ * - `"globals"` writes to `deploy.arc.env` (cross-env secrets like the
68
+ * observability Grafana password — one stack-wide value).
69
+ * - An env name writes to `deploy.arc.<envName>.env` (per-env secrets
70
+ * like the Postgres sidecar password — one per deployment env).
71
+ *
72
+ * Used to bootstrap secrets the framework owns end-to-end without forcing
73
+ * the user to hand-edit the file before the first deploy.
74
+ */
75
+ export function ensurePersistedSecret(
76
+ rootDir: string,
77
+ scope: "globals" | string,
78
+ key: string,
79
+ generate: () => string,
80
+ ): string {
81
+ if (process.env[key]) return process.env[key]!;
82
+
83
+ const fileName = scope === "globals" ? "deploy.arc.env" : `deploy.arc.${scope}.env`;
84
+ const path = join(rootDir, fileName);
85
+ if (existsSync(path)) {
86
+ const existing = parseEnvFile(readFileSync(path, "utf-8"), path);
87
+ if (existing[key]) {
88
+ process.env[key] = existing[key];
89
+ return existing[key];
90
+ }
91
+ }
92
+
93
+ const value = generate();
94
+ const prefix = existsSync(path) && !readFileSync(path, "utf-8").endsWith("\n") ? "\n" : "";
95
+ appendFileSync(path, `${prefix}${key}=${value}\n`);
96
+ process.env[key] = value;
97
+ return value;
98
+ }
99
+
60
100
  // ---------------------------------------------------------------------------
61
101
  // Parser
62
102
  // ---------------------------------------------------------------------------
@@ -0,0 +1,293 @@
1
+ import type { DeployConfig, DeployObservability } from "./config";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Observability stack config templates.
5
+ //
6
+ // All strings are deterministic for the inputs (cfg + retention) — no random
7
+ // IDs, no timestamps — so re-running deploy with unchanged config is a no-op
8
+ // at the file-write level. Bootstrap diffs filesystem before bouncing
9
+ // services, so this matters.
10
+ //
11
+ // Defaults:
12
+ // - traces: 7d retention (Tempo block storage on local disk)
13
+ // - logs: 7d retention (Loki chunks on local disk)
14
+ // - metrics: 30d retention (Prometheus TSDB on local disk)
15
+ //
16
+ // Tail sampling: every error + every span >500ms + 10% random. Decided in
17
+ // the collector so per-service SDKs can be left at always-on without
18
+ // flooding the backend.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DEFAULT_RETENTION = {
22
+ traces: "168h", // 7d
23
+ logs: "168h",
24
+ metrics: "30d",
25
+ } as const;
26
+
27
+ function pickRetention(o: DeployObservability | undefined) {
28
+ return {
29
+ traces: o?.retention?.traces ?? DEFAULT_RETENTION.traces,
30
+ logs: o?.retention?.logs ?? DEFAULT_RETENTION.logs,
31
+ metrics: o?.retention?.metrics ?? DEFAULT_RETENTION.metrics,
32
+ };
33
+ }
34
+
35
+ /** OpenTelemetry Collector — receives OTLP from app containers + browser,
36
+ * applies tail sampling, fans out to Tempo (traces), Loki (logs),
37
+ * Prometheus remote-write (metrics). */
38
+ export function generateOtelCollectorConfig(cfg: DeployConfig): string {
39
+ const envNames = Object.keys(cfg.envs);
40
+ return `# Generated by \`arc platform deploy\` — do not edit by hand.
41
+ receivers:
42
+ otlp:
43
+ protocols:
44
+ grpc:
45
+ endpoint: 0.0.0.0:4317
46
+ http:
47
+ endpoint: 0.0.0.0:4318
48
+ cors:
49
+ allowed_origins:
50
+ ${envNames.map((name) => ` - "https://${cfg.envs[name]!.domain}"`).join("\n")}
51
+ allowed_headers:
52
+ - traceparent
53
+ - tracestate
54
+ - content-type
55
+
56
+ processors:
57
+ batch:
58
+ timeout: 5s
59
+ send_batch_size: 512
60
+ send_batch_max_size: 1024
61
+
62
+ # Tail-based sampling — applied after a full trace has been assembled.
63
+ # Errors and slow traces are kept 100%, everything else at 10%.
64
+ tail_sampling:
65
+ decision_wait: 10s
66
+ num_traces: 50000
67
+ expected_new_traces_per_sec: 100
68
+ policies:
69
+ - name: errors
70
+ type: status_code
71
+ status_code: { status_codes: [ERROR] }
72
+ - name: slow
73
+ type: latency
74
+ latency: { threshold_ms: 500 }
75
+ - name: random_10pct
76
+ type: probabilistic
77
+ probabilistic: { sampling_percentage: 10 }
78
+
79
+ # Drop high-cardinality / PII attributes that might slip past app-side
80
+ # sanitization. Belt-and-suspenders before they hit long-term storage.
81
+ attributes:
82
+ actions:
83
+ - key: http.request.header.authorization
84
+ action: delete
85
+ - key: http.request.header.cookie
86
+ action: delete
87
+
88
+ exporters:
89
+ otlp/tempo:
90
+ endpoint: tempo:4317
91
+ tls:
92
+ insecure: true
93
+
94
+ otlphttp/loki:
95
+ endpoint: http://loki:3100/otlp
96
+ tls:
97
+ insecure: true
98
+
99
+ prometheusremotewrite:
100
+ endpoint: http://prometheus:9090/api/v1/write
101
+ tls:
102
+ insecure: true
103
+
104
+ extensions:
105
+ health_check: {}
106
+ zpages: {}
107
+
108
+ service:
109
+ extensions: [health_check, zpages]
110
+ pipelines:
111
+ traces:
112
+ receivers: [otlp]
113
+ processors: [tail_sampling, attributes, batch]
114
+ exporters: [otlp/tempo]
115
+ logs:
116
+ receivers: [otlp]
117
+ processors: [attributes, batch]
118
+ exporters: [otlphttp/loki]
119
+ metrics:
120
+ receivers: [otlp]
121
+ processors: [batch]
122
+ exporters: [prometheusremotewrite]
123
+ `;
124
+ }
125
+
126
+ /** Grafana Tempo — single-binary mode with local block storage. */
127
+ export function generateTempoConfig(cfg: DeployConfig): string {
128
+ const retention = pickRetention(cfg.observability);
129
+ return `# Generated by \`arc platform deploy\` — do not edit by hand.
130
+ server:
131
+ http_listen_port: 3200
132
+ grpc_listen_port: 9095
133
+
134
+ distributor:
135
+ receivers:
136
+ otlp:
137
+ protocols:
138
+ grpc:
139
+ endpoint: 0.0.0.0:4317
140
+ http:
141
+ endpoint: 0.0.0.0:4318
142
+
143
+ ingester:
144
+ trace_idle_period: 10s
145
+ max_block_bytes: 1048576
146
+ max_block_duration: 5m
147
+
148
+ compactor:
149
+ compaction:
150
+ block_retention: ${retention.traces}
151
+
152
+ storage:
153
+ trace:
154
+ backend: local
155
+ local:
156
+ path: /var/tempo/blocks
157
+ wal:
158
+ path: /var/tempo/wal
159
+
160
+ metrics_generator:
161
+ registry:
162
+ external_labels:
163
+ source: tempo
164
+ storage:
165
+ path: /var/tempo/generator/wal
166
+ remote_write:
167
+ - url: http://prometheus:9090/api/v1/write
168
+ send_exemplars: true
169
+
170
+ overrides:
171
+ defaults:
172
+ metrics_generator:
173
+ processors: [service-graphs, span-metrics]
174
+ `;
175
+ }
176
+
177
+ /** Loki — single-binary mode, filesystem chunks. */
178
+ export function generateLokiConfig(cfg: DeployConfig): string {
179
+ const retention = pickRetention(cfg.observability);
180
+ return `# Generated by \`arc platform deploy\` — do not edit by hand.
181
+ auth_enabled: false
182
+
183
+ server:
184
+ http_listen_port: 3100
185
+
186
+ common:
187
+ instance_addr: 127.0.0.1
188
+ path_prefix: /loki
189
+ storage:
190
+ filesystem:
191
+ chunks_directory: /loki/chunks
192
+ rules_directory: /loki/rules
193
+ replication_factor: 1
194
+ ring:
195
+ kvstore:
196
+ store: inmemory
197
+
198
+ schema_config:
199
+ configs:
200
+ - from: 2024-01-01
201
+ store: tsdb
202
+ object_store: filesystem
203
+ schema: v13
204
+ index:
205
+ prefix: index_
206
+ period: 24h
207
+
208
+ limits_config:
209
+ retention_period: ${retention.logs}
210
+ allow_structured_metadata: true
211
+
212
+ compactor:
213
+ working_directory: /loki/compactor
214
+ retention_enabled: true
215
+ delete_request_store: filesystem
216
+ `;
217
+ }
218
+
219
+ /** Prometheus — accepts remote_write from the collector, scrapes itself. */
220
+ export function generatePrometheusConfig(cfg: DeployConfig): string {
221
+ const retention = pickRetention(cfg.observability);
222
+ return `# Generated by \`arc platform deploy\` — do not edit by hand.
223
+ global:
224
+ scrape_interval: 15s
225
+ evaluation_interval: 15s
226
+
227
+ scrape_configs:
228
+ - job_name: prometheus
229
+ static_configs:
230
+ - targets: [localhost:9090]
231
+ - job_name: otel-collector
232
+ static_configs:
233
+ - targets: [otel-collector:8888]
234
+
235
+ storage:
236
+ tsdb:
237
+ retention.time: ${retention.metrics}
238
+
239
+ # Note: remote-write inbound is enabled via the --web.enable-remote-write-receiver
240
+ # command-line flag (set in docker-compose), not here.
241
+ `;
242
+ }
243
+
244
+ /** Grafana datasource provisioning — Tempo + Loki + Prometheus, all pre-wired. */
245
+ export function generateGrafanaDatasources(): string {
246
+ return `# Generated by \`arc platform deploy\` — do not edit by hand.
247
+ apiVersion: 1
248
+ datasources:
249
+ - name: Tempo
250
+ type: tempo
251
+ access: proxy
252
+ url: http://tempo:3200
253
+ uid: tempo
254
+ jsonData:
255
+ tracesToLogsV2:
256
+ datasourceUid: loki
257
+ spanStartTimeShift: -5m
258
+ spanEndTimeShift: 5m
259
+ serviceMap:
260
+ datasourceUid: prometheus
261
+ - name: Loki
262
+ type: loki
263
+ access: proxy
264
+ url: http://loki:3100
265
+ uid: loki
266
+ jsonData:
267
+ derivedFields:
268
+ - datasourceUid: tempo
269
+ matcherRegex: "trace_id=(\\\\w+)"
270
+ name: TraceID
271
+ url: $\${__value.raw}
272
+ - name: Prometheus
273
+ type: prometheus
274
+ access: proxy
275
+ url: http://prometheus:9090
276
+ uid: prometheus
277
+ isDefault: true
278
+ `;
279
+ }
280
+
281
+ /** All config files needed on the host. Returns map of relative-path → contents
282
+ * so bootstrap can write+upload them in one pass. */
283
+ export function generateObservabilityConfigs(
284
+ cfg: DeployConfig,
285
+ ): Record<string, string> {
286
+ return {
287
+ "observability/otel-collector-config.yaml": generateOtelCollectorConfig(cfg),
288
+ "observability/tempo.yaml": generateTempoConfig(cfg),
289
+ "observability/loki-config.yaml": generateLokiConfig(cfg),
290
+ "observability/prometheus.yml": generatePrometheusConfig(cfg),
291
+ "observability/grafana-datasources.yaml": generateGrafanaDatasources(),
292
+ };
293
+ }
@@ -11,7 +11,7 @@ import {
11
11
  import { existsSync, mkdirSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
- import type { BuildManifest, WorkspaceInfo } from "./shared";
14
+ import { err, ok, type BuildManifest, type WorkspaceInfo } from "./shared";
15
15
  import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -52,17 +52,39 @@ export async function initContextHandler(
52
52
  context: any,
53
53
  dbPath: string,
54
54
  ): Promise<ContextHandler> {
55
+ const factory = await resolveDbAdapterFactory(dbPath);
56
+ const dbAdapter = factory(context);
57
+ const handler = new ContextHandler(context, dbAdapter);
58
+ await handler.init();
59
+ return handler;
60
+ }
61
+
62
+ /**
63
+ * Pick the database adapter factory based on runtime configuration.
64
+ *
65
+ * - When `DATABASE_URL` is set (Postgres sidecar generated by deploy, or any
66
+ * external Postgres the operator wired up themselves), connect over the
67
+ * network using the postgres adapter.
68
+ * - Otherwise fall back to the SQLite file at `dbPath`. The directory is
69
+ * created on demand so first-boot in a fresh data volume just works.
70
+ *
71
+ * Both adapters share the `DBAdapterFactory` contract from arc-core, so the
72
+ * caller never needs to know which backend is live.
73
+ */
74
+ async function resolveDbAdapterFactory(dbPath: string) {
75
+ const databaseUrl = process.env.DATABASE_URL;
76
+ if (databaseUrl && databaseUrl.length > 0) {
77
+ const { createPostgreSQLAdapterFactoryFromUrl } = await import(
78
+ "@arcote.tech/arc-adapter-db-postgres"
79
+ );
80
+ return createPostgreSQLAdapterFactoryFromUrl(databaseUrl);
81
+ }
55
82
  const { createBunSQLiteAdapterFactory } = await import(
56
83
  "@arcote.tech/arc-adapter-db-sqlite"
57
84
  );
58
-
59
85
  const dbDir = dbPath.substring(0, dbPath.lastIndexOf("/"));
60
86
  if (dbDir) mkdirSync(dbDir, { recursive: true });
61
-
62
- const dbAdapter = createBunSQLiteAdapterFactory(dbPath)(context);
63
- const handler = new ContextHandler(context, dbAdapter);
64
- await handler.init();
65
- return handler;
87
+ return createBunSQLiteAdapterFactory(dbPath);
66
88
  }
67
89
 
68
90
  // ---------------------------------------------------------------------------
@@ -75,6 +97,21 @@ export function generateShellHtml(
75
97
  initial?: { file: string; hash: string },
76
98
  stylesHash?: string,
77
99
  ): string {
100
+ // OpenTelemetry config — injected as a global so the browser SDK chunk
101
+ // (lazy-loaded by start-app.ts) can pick it up without a fetch. Endpoint
102
+ // is same-origin so Caddy can apply CORS + auth uniformly.
103
+ const otelConfig = process.env.ARC_OTEL_ENABLED === "true"
104
+ ? {
105
+ enabled: true,
106
+ endpoint: "/otel",
107
+ serviceName: process.env.OTEL_SERVICE_NAME ?? `${appName}-browser`,
108
+ environment: process.env.NODE_ENV === "production" ? "production" : "development",
109
+ sampleRate: Number(process.env.ARC_OTEL_BROWSER_SAMPLE_RATE ?? "0.1"),
110
+ }
111
+ : null;
112
+ const otelTag = otelConfig
113
+ ? `\n <script>window.__ARC_OTEL_CONFIG=${JSON.stringify(otelConfig)};</script>`
114
+ : "";
78
115
  // Initial bundle carries framework, public modules, and PlatformApp re-export.
79
116
  // No importmap — single Bun.build with splitting:true inlines + dedups everything
80
117
  // across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
@@ -95,7 +132,7 @@ export function generateShellHtml(
95
132
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
96
133
  <link rel="stylesheet" href="/styles.css${stylesQs}" />
97
134
  <link rel="stylesheet" href="/theme.css${stylesQs}" />
98
- <link rel="modulepreload" href="${initialUrl}" />
135
+ <link rel="modulepreload" href="${initialUrl}" />${otelTag}
99
136
  </head>
100
137
  <body>
101
138
  <div id="root"></div>
@@ -458,6 +495,30 @@ export async function startPlatformServer(
458
495
  ): Promise<PlatformServer> {
459
496
  const { ws, port, devMode, context } = opts;
460
497
  ensureModuleSigSecret(ws, !!devMode);
498
+
499
+ // OpenTelemetry — only when explicitly enabled (deploy injects the env
500
+ // when `observability.enabled` is set in deploy.arc.json). Dynamic import
501
+ // keeps the OTel SDK out of bundles that don't use it.
502
+ let telemetry: import("@arcote.tech/arc-otel").ArcTelemetry | undefined;
503
+ let telemetryShutdown: (() => Promise<void>) | undefined;
504
+ if (process.env.ARC_OTEL_ENABLED === "true") {
505
+ try {
506
+ const { initServerTelemetry } = await import("@arcote.tech/arc-otel/server");
507
+ const init = initServerTelemetry({
508
+ serviceName: process.env.OTEL_SERVICE_NAME ?? ws.appName,
509
+ environment: "server",
510
+ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
511
+ mode: devMode ? "development" : "production",
512
+ sampleRate: devMode ? 1.0 : 1.0, // head-based 100%, collector tail-samples
513
+ });
514
+ telemetry = init.telemetry;
515
+ telemetryShutdown = init.shutdown;
516
+ ok("Telemetry enabled — exporting to " + process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
517
+ } catch (e) {
518
+ err(`Failed to init telemetry: ${(e as Error).message}`);
519
+ }
520
+ }
521
+
461
522
  const moduleAccessMap = opts.moduleAccess ?? new Map();
462
523
  let manifest = opts.manifest;
463
524
  const getManifest = () => manifest;
@@ -536,17 +597,14 @@ export async function startPlatformServer(
536
597
  };
537
598
  }
538
599
 
539
- // Context available — use createArcServer with platform handlers on top
540
- const { createBunSQLiteAdapterFactory } = await import(
541
- "@arcote.tech/arc-adapter-db-sqlite"
542
- );
600
+ // Context available — use createArcServer with platform handlers on top.
601
+ // `resolveDbAdapterFactory` picks SQLite or Postgres based on DATABASE_URL.
543
602
  const dbPath = opts.dbPath || join(ws.arcDir, "data", "arc.db");
544
- const dbDir = dbPath.substring(0, dbPath.lastIndexOf("/"));
545
- if (dbDir) mkdirSync(dbDir, { recursive: true });
603
+ const dbAdapterFactory = await resolveDbAdapterFactory(dbPath);
546
604
 
547
605
  const arcServer = await createArcServer({
548
606
  context,
549
- dbAdapterFactory: createBunSQLiteAdapterFactory(dbPath),
607
+ dbAdapterFactory,
550
608
  port,
551
609
  httpHandlers: [
552
610
  // Platform-specific handlers (checked AFTER arc handlers)
@@ -556,6 +614,7 @@ export async function startPlatformServer(
556
614
  spaFallbackHandler(getShellHtml),
557
615
  ],
558
616
  onWsClose: (clientId) => cleanupClientSubs(clientId),
617
+ telemetry,
559
618
  });
560
619
 
561
620
  return {
@@ -564,6 +623,9 @@ export async function startPlatformServer(
564
623
  connectionManager: arcServer.connectionManager,
565
624
  setManifest,
566
625
  notifyReload,
567
- stop: () => arcServer.stop(),
626
+ stop: () => {
627
+ arcServer.stop();
628
+ void telemetryShutdown?.();
629
+ },
568
630
  };
569
631
  }