@dinoxx/dinox-cli 2.13.0 → 2.14.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.
Files changed (58) hide show
  1. package/dist/cli.js +5 -2
  2. package/dist/commands/auth/index.d.ts +2 -7
  3. package/dist/commands/auth/index.js +26 -51
  4. package/dist/commands/doctor.d.ts +4 -1
  5. package/dist/commands/doctor.js +20 -1
  6. package/dist/commands/graph/index.js +2 -1
  7. package/dist/commands/notes/contentPatch.js +2 -1
  8. package/dist/commands/notes/export.js +3 -2
  9. package/dist/commands/runtime.d.ts +8 -0
  10. package/dist/commands/runtime.js +49 -1
  11. package/dist/commands/schema/globalOptions.d.ts +1 -0
  12. package/dist/commands/schema/globalOptions.js +1 -0
  13. package/dist/commands/schema/renderSkills.js +1 -0
  14. package/dist/commands/schema/spec.js +9 -1
  15. package/dist/commands/storage/repo/operations.js +3 -1
  16. package/dist/commands/sync.d.ts +2 -1
  17. package/dist/commands/sync.js +24 -5
  18. package/dist/daemon/client.js +32 -2
  19. package/dist/daemon/connectivity.d.ts +20 -0
  20. package/dist/daemon/connectivity.js +42 -0
  21. package/dist/daemon/ensure.js +48 -12
  22. package/dist/daemon/forward.d.ts +34 -0
  23. package/dist/daemon/forward.js +146 -0
  24. package/dist/daemon/fresh.d.ts +4 -0
  25. package/dist/daemon/fresh.js +10 -0
  26. package/dist/daemon/index.d.ts +11 -6
  27. package/dist/daemon/index.js +90 -25
  28. package/dist/daemon/launch.d.ts +13 -1
  29. package/dist/daemon/launch.js +31 -2
  30. package/dist/daemon/lifecycle.d.ts +3 -1
  31. package/dist/daemon/lifecycle.js +27 -3
  32. package/dist/daemon/pid.d.ts +8 -0
  33. package/dist/daemon/pid.js +20 -0
  34. package/dist/daemon/route.js +2 -1
  35. package/dist/daemon/server.d.ts +44 -0
  36. package/dist/daemon/server.js +175 -3
  37. package/dist/daemon/supervise.d.ts +6 -6
  38. package/dist/daemon/supervise.js +12 -10
  39. package/dist/daemon/supervisor.d.ts +21 -5
  40. package/dist/daemon/supervisor.js +125 -6
  41. package/dist/daemon/types.d.ts +16 -0
  42. package/dist/dinox.js +41 -64
  43. package/dist/gateway/mcpCommandTools.d.ts +9 -0
  44. package/dist/gateway/mcpCommandTools.js +40 -10
  45. package/dist/powersync/runtime.d.ts +2 -0
  46. package/dist/powersync/runtime.js +3 -1
  47. package/dist/requests/store.d.ts +10 -0
  48. package/dist/requests/store.js +190 -0
  49. package/dist/runCli.d.ts +2 -0
  50. package/dist/runCli.js +64 -0
  51. package/dist/utils/argValue.js +2 -1
  52. package/dist/utils/errorCodes.d.ts +20 -0
  53. package/dist/utils/errorCodes.js +5 -0
  54. package/dist/utils/output.d.ts +2 -0
  55. package/dist/utils/output.js +31 -7
  56. package/dist/utils/requestContext.d.ts +24 -0
  57. package/dist/utils/requestContext.js +13 -0
  58. package/package.json +2 -1
@@ -1,4 +1,6 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, watch, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigFilePath } from '../config/paths.js';
2
4
  import { DaemonServer } from './server.js';
3
5
  import { DEFAULT_DAEMON_PORT } from './types.js';
4
6
  import { acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, removePid, } from './pid.js';
@@ -13,6 +15,7 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
13
15
  import { withAsyncRelease } from '../utils/asyncRelease.js';
14
16
  import { remainingDeadlineMs } from '../utils/deadline.js';
15
17
  import { getPackageVersion } from '../utils/version.js';
18
+ import { matchesRuntimeTokenBinding } from './fresh.js';
16
19
  import { DAEMON_CHILD_ENV, daemonServiceSpec, envTokenOverridesLogin, launchDaemon } from './launch.js';
17
20
  import { getDaemonLogPath, rotateDaemonLog, writeDaemonLogLine } from './log.js';
18
21
  import { DAEMON_SERVICE_ENV, getDaemonService } from './supervisor.js';
@@ -99,14 +102,6 @@ async function expectedDaemonIdentity(baseConfig, timeoutMs) {
99
102
  runtimeConfigFingerprint: fingerprintDaemonRuntimeConfig(config),
100
103
  };
101
104
  }
102
- function matchesRuntimeTokenBinding(info, version, config) {
103
- return Boolean(config.auth.authorization
104
- && info.authenticated
105
- && info.version === version
106
- && info.tokenFingerprint === (config.auth.tokenFingerprint ?? null)
107
- && info.runtimeConfigFingerprint === fingerprintDaemonRuntimeConfig(config)
108
- && (!config.auth.userId || info.userId === config.auth.userId));
109
- }
110
105
  export async function startDaemonLocked(port, detached, options = {}) {
111
106
  const maximumTimeoutMs = options.timeoutMs === undefined
112
107
  ? undefined
@@ -142,7 +137,7 @@ export async function startDaemonLocked(port, detached, options = {}) {
142
137
  }
143
138
  const expected = await expectedDaemonIdentity(baseConfig, remainingStartTimeoutMs('auth-hydration'));
144
139
  if (detached) {
145
- const launch = await launchDaemon(port);
140
+ const launch = await launchDaemon(port, { takeover: options.takeover });
146
141
  const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, remainingStartTimeoutMs('ready-poll'));
147
142
  if (!ready) {
148
143
  launch.abort();
@@ -172,7 +167,16 @@ async function serveDaemonInForeground(port, options) {
172
167
  if (!options.quiet) {
173
168
  printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
174
169
  }
175
- const server = new DaemonServer();
170
+ const stopAndExit = async (reason, exitCode, details) => {
171
+ writeDaemonLogLine('dinox-daemon', 'stopping', { reason, ...details });
172
+ const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
173
+ forceExit.unref();
174
+ await server.stop().catch(() => undefined);
175
+ process.exit(exitCode);
176
+ };
177
+ const server = new DaemonServer({
178
+ onStopRequested: (reason, details) => stopAndExit(reason, typeof details?.exitCode === 'number' ? details.exitCode : 0, details),
179
+ });
176
180
  try {
177
181
  await server.initialize();
178
182
  await server.start(port);
@@ -189,13 +193,6 @@ async function serveDaemonInForeground(port, options) {
189
193
  version: getPackageVersion(),
190
194
  supervisor: process.env[DAEMON_SERVICE_ENV] ?? null,
191
195
  });
192
- const stopAndExit = async (reason, exitCode) => {
193
- writeDaemonLogLine('dinox-daemon', 'stopping', { reason });
194
- const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
195
- forceExit.unref();
196
- await server.stop().catch(() => undefined);
197
- process.exit(exitCode);
198
- };
199
196
  process.on('SIGINT', () => {
200
197
  if (!options.quiet) {
201
198
  printHumanReadableLine('');
@@ -247,11 +244,17 @@ export async function stopDaemonLocked() {
247
244
  await shutdownLegacyDaemon(info, DAEMON_STOP_TIMEOUT_MS);
248
245
  return { stopped: true, wasRunning: true, stalePidFile: false };
249
246
  }
247
+ const service = getDaemonService();
248
+ const supervised = Boolean(service?.isInstalled() && info.supervisor === service.kind);
250
249
  try {
251
- await requestDaemonShutdown(info.instanceId, 2_000);
250
+ // Through the manager: with KeepAlive a plain socket shutdown is respawned.
251
+ if (supervised)
252
+ await service.stop();
253
+ if (!supervised || service.kind === 'schtasks')
254
+ await requestDaemonShutdown(info.instanceId, 2_000);
252
255
  }
253
256
  catch (error) {
254
- throw new DinoxError(`Failed to stop daemon through its private socket: ${error.message}`, {
257
+ throw new DinoxError(`Failed to stop daemon ${supervised ? `through ${service.kind}` : 'through its private socket'}: ${error.message}`, {
255
258
  code: 'DAEMON_STOP_FAILED',
256
259
  suggestion: 'The daemon PID was not signaled because its process identity could not be verified safely.',
257
260
  });
@@ -317,6 +320,20 @@ async function daemonStatus() {
317
320
  }
318
321
  }
319
322
  export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options = {}) {
323
+ const info = await getDaemonInfo();
324
+ const service = getDaemonService();
325
+ if (info && service?.isInstalled() && info.supervisor === service.kind
326
+ && service.isCurrent(daemonServiceSpec(service, port))) {
327
+ const expected = await expectedDaemonIdentity();
328
+ await service.restart();
329
+ if (service.kind === 'schtasks')
330
+ await requestDaemonShutdown(info.instanceId, 2_000);
331
+ const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, DAEMON_SERVICE_READY_TIMEOUT_MS);
332
+ if (!ready)
333
+ throw new DinoxError('Restarted service did not become ready', { code: 'DAEMON_START_FAILED' });
334
+ return { stopped: true, running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket',
335
+ detached: true, alreadyRunning: false, service: service.kind };
336
+ }
320
337
  const stopResult = await stopDaemonLocked();
321
338
  await new Promise((resolve) => setTimeout(resolve, 1000));
322
339
  const startResult = await startDaemonLocked(port, true, options);
@@ -388,6 +405,8 @@ export async function uninstallDaemonService() {
388
405
  const info = await getDaemonInfo();
389
406
  if (info?.supervisor === service.kind)
390
407
  await stopDaemonLocked();
408
+ else
409
+ await service.stop();
391
410
  }
392
411
  return {
393
412
  uninstalled: await service.uninstall(),
@@ -456,17 +475,63 @@ function printDaemonUninstallResult(result) {
456
475
  ? `Removed ${result.service} service; the daemon is stopped.`
457
476
  : 'The daemon service is not installed.');
458
477
  }
478
+ export async function waitForSavedDaemonLogin(overrides = {}) {
479
+ const hasAuth = overrides.hasAuth ?? (async () => Boolean(normalizeAuthToken((await loadConfig()).auth?.authorization)));
480
+ const exit = overrides.exit ?? ((code) => process.exit(code));
481
+ const onSignal = overrides.onSignal ?? ((handler) => {
482
+ process.on('SIGTERM', handler);
483
+ process.on('SIGINT', handler);
484
+ return () => { process.off('SIGTERM', handler); process.off('SIGINT', handler); };
485
+ });
486
+ const watchConfig = overrides.watchConfig ?? ((check) => {
487
+ const dir = path.dirname(getConfigFilePath());
488
+ mkdirSync(dir, { recursive: true });
489
+ try {
490
+ const watcher = watch(dir, check);
491
+ return () => watcher.close();
492
+ }
493
+ catch {
494
+ return () => undefined;
495
+ }
496
+ });
497
+ writeDaemonLogLine('dinox-daemon', 'not signed in; waiting for saved login');
498
+ await new Promise((resolve) => {
499
+ let done = false;
500
+ let closeWatch = () => undefined;
501
+ let closeSignals = () => undefined;
502
+ const finish = (code) => {
503
+ if (done)
504
+ return;
505
+ done = true;
506
+ clearInterval(timer);
507
+ closeWatch();
508
+ closeSignals();
509
+ exit(code);
510
+ resolve();
511
+ };
512
+ const check = () => {
513
+ void hasAuth().then((authorized) => {
514
+ if (authorized && !done) {
515
+ writeDaemonLogLine('dinox-daemon', 'saved login found; restarting');
516
+ finish(75);
517
+ }
518
+ }).catch(() => undefined);
519
+ };
520
+ const timer = setInterval(check, overrides.pollMs ?? 60_000);
521
+ closeWatch = watchConfig(check);
522
+ closeSignals = onSignal(() => finish(0));
523
+ check();
524
+ });
525
+ }
459
526
  async function runDaemonStart(context) {
460
527
  const port = parsePort(context.options.port);
461
528
  const structured = Boolean(getRequestedStructuredOutputFormat());
462
529
  if (context.options.detach === false && process.env[DAEMON_SERVICE_ENV]
463
530
  && !resolveConfig(await loadConfig()).auth.authorization) {
464
- // Started at login by the service while signed out: exit cleanly so the
465
- // service does not retry; the next command after `dino auth login` starts it.
466
- writeDaemonLogLine('dinox-daemon', 'not signed in; staying stopped until `dino auth login`');
531
+ await waitForSavedDaemonLogin();
467
532
  return;
468
533
  }
469
- const result = await startDaemon(port, context.options.detach !== false, { quiet: structured });
534
+ const result = await startDaemon(port, context.options.detach !== false, { quiet: structured, takeover: true });
470
535
  if (!result.running) {
471
536
  throw new DinoxError('Failed to start daemon', { code: 'DAEMON_START_FAILED' });
472
537
  }
@@ -513,7 +578,7 @@ async function runDaemonUninstall() {
513
578
  }
514
579
  async function runDaemonRestart(context) {
515
580
  const structured = Boolean(getRequestedStructuredOutputFormat());
516
- const result = await restartDaemon(parsePort(context.options.port), { quiet: structured });
581
+ const result = await restartDaemon(parsePort(context.options.port), { quiet: structured, takeover: true });
517
582
  if (structured) {
518
583
  printSuccess(result);
519
584
  return;
@@ -22,10 +22,22 @@ export declare function envTokenOverridesLogin(raw: DinoxConfig, env?: NodeJS.Pr
22
22
  export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
23
23
  /** The installed service when this caller can use it, else null. */
24
24
  export declare function usableDaemonService(): Promise<DaemonService | null>;
25
+ /**
26
+ * The entrypoint of another dino install (e.g. a global install next to a
27
+ * source checkout) that the service definition runs, or null when it runs
28
+ * this CLI or an install that no longer exists. Automatic paths must not
29
+ * rewrite or restart a service another install owns: each install would
30
+ * otherwise keep switching it to its own version.
31
+ */
32
+ export declare function foreignServiceOwner(service: DaemonService, entrypoint?: string | undefined): string | null;
25
33
  /**
26
34
  * Starts a daemon. Caller must hold the daemon start lock and have stopped any
27
35
  * stale daemon. Uses the installed service when there is one (refreshing its
28
36
  * definition if this CLI's program, arguments or environment changed, e.g.
29
37
  * after `dino update`), otherwise a detached child with a clean environment.
38
+ * A service another install owns is only started, never rewritten, unless
39
+ * `takeover` is set by an explicit `dino daemon start/restart`.
30
40
  */
31
- export declare function launchDaemon(port: number): Promise<DaemonLaunch>;
41
+ export declare function launchDaemon(port: number, options?: {
42
+ takeover?: boolean;
43
+ }): Promise<DaemonLaunch>;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import { loadConfig } from '../config/store.js';
4
5
  import { DINOX_TOKEN_ENV_VAR, normalizeAuthToken, readEnvAuthToken } from '../utils/authToken.js';
@@ -52,6 +53,32 @@ export async function usableDaemonService() {
52
53
  return null;
53
54
  return service;
54
55
  }
56
+ function samePath(a, b) {
57
+ const resolve = (value) => {
58
+ try {
59
+ return realpathSync(value);
60
+ }
61
+ catch {
62
+ return value;
63
+ }
64
+ };
65
+ return resolve(a) === resolve(b);
66
+ }
67
+ /**
68
+ * The entrypoint of another dino install (e.g. a global install next to a
69
+ * source checkout) that the service definition runs, or null when it runs
70
+ * this CLI or an install that no longer exists. Automatic paths must not
71
+ * rewrite or restart a service another install owns: each install would
72
+ * otherwise keep switching it to its own version.
73
+ */
74
+ export function foreignServiceOwner(service, entrypoint = process.argv[1]) {
75
+ const command = service.installedCommand();
76
+ const daemonIndex = command?.indexOf('daemon') ?? -1;
77
+ const owner = daemonIndex > 0 ? command[daemonIndex - 1] : null;
78
+ if (!owner || !entrypoint || samePath(owner, entrypoint) || !existsSync(owner))
79
+ return null;
80
+ return owner;
81
+ }
55
82
  function spawnDetached(port) {
56
83
  const { program, args } = daemonCommandLine(port);
57
84
  const log = openDaemonLogForChild();
@@ -76,14 +103,16 @@ function spawnDetached(port) {
76
103
  * stale daemon. Uses the installed service when there is one (refreshing its
77
104
  * definition if this CLI's program, arguments or environment changed, e.g.
78
105
  * after `dino update`), otherwise a detached child with a clean environment.
106
+ * A service another install owns is only started, never rewritten, unless
107
+ * `takeover` is set by an explicit `dino daemon start/restart`.
79
108
  */
80
- export async function launchDaemon(port) {
109
+ export async function launchDaemon(port, options = {}) {
81
110
  const service = await usableDaemonService();
82
111
  if (!service)
83
112
  return spawnDetached(port);
84
113
  ensureDaemonDir();
85
114
  const spec = daemonServiceSpec(service, port);
86
- if (!service.isCurrent(spec)) {
115
+ if (!service.isCurrent(spec) && (options.takeover || !foreignServiceOwner(service))) {
87
116
  await service.install(spec);
88
117
  }
89
118
  else {
@@ -3,7 +3,7 @@ import { printHumanReadableWarning } from '../utils/output.js';
3
3
  import { getPackageVersion } from '../utils/version.js';
4
4
  import { getDaemonInfo, shutdownDaemonForExclusiveAccess } from './client.js';
5
5
  import { startDaemonLocked } from './index.js';
6
- import { acquireDaemonStartLock, readPid, type DaemonPidInfo } from './pid.js';
6
+ import { acquireDaemonStartLock, acquireDaemonExclusiveIntentLock, readPid, type DaemonPidInfo } from './pid.js';
7
7
  import { type DaemonPingResult } from './types.js';
8
8
  export type DaemonSnapshot = {
9
9
  info: DaemonPingResult | null;
@@ -21,10 +21,12 @@ export type DaemonExclusiveAccessControl = {
21
21
  };
22
22
  type DaemonLifecycleDependencies = {
23
23
  acquireStartLock: typeof acquireDaemonStartLock;
24
+ acquireIntentLock: typeof acquireDaemonExclusiveIntentLock;
24
25
  getDaemonInfo: typeof getDaemonInfo;
25
26
  readPid: typeof readPid;
26
27
  shutdownDaemon: typeof shutdownDaemonForExclusiveAccess;
27
28
  startDaemonLocked: typeof startDaemonLocked;
29
+ startInstalledService: () => Promise<boolean>;
28
30
  getPackageVersion: typeof getPackageVersion;
29
31
  printWarning: typeof printHumanReadableWarning;
30
32
  };
@@ -6,15 +6,27 @@ import { DinoxError } from '../utils/errors.js';
6
6
  import { printHumanReadableWarning } from '../utils/output.js';
7
7
  import { getPackageVersion } from '../utils/version.js';
8
8
  import { getDaemonInfo, isDaemonFresh, shutdownDaemonForExclusiveAccess, } from './client.js';
9
+ import { getDaemonService } from './supervisor.js';
9
10
  import { startDaemonLocked } from './index.js';
10
- import { acquireDaemonStartLock, getDaemonSocketPath, readPid, } from './pid.js';
11
+ import { acquireDaemonStartLock, acquireDaemonExclusiveIntentLock, getDaemonSocketPath, readPid, } from './pid.js';
11
12
  import { DEFAULT_DAEMON_PORT } from './types.js';
13
+ /** Matches the service readiness budget; a cold daemon start takes a few seconds. */
14
+ const DAEMON_RESTORE_TIMEOUT_MS = 20_000;
12
15
  const daemonLifecycleDependencies = {
13
16
  acquireStartLock: acquireDaemonStartLock,
17
+ acquireIntentLock: acquireDaemonExclusiveIntentLock,
14
18
  getDaemonInfo,
15
19
  readPid,
16
20
  shutdownDaemon: shutdownDaemonForExclusiveAccess,
17
21
  startDaemonLocked,
22
+ async startInstalledService() {
23
+ const service = getDaemonService();
24
+ if (!service?.isInstalled())
25
+ return false;
26
+ // Not restart(): the manager may already be bringing it back.
27
+ await service.start();
28
+ return true;
29
+ },
18
30
  getPackageVersion,
19
31
  printWarning: printHumanReadableWarning,
20
32
  };
@@ -78,13 +90,17 @@ export async function suspendDaemonForExclusiveAccessLocked(config, timeoutMs, o
78
90
  }
79
91
  /** Caller must hold the daemon start lock. */
80
92
  export async function restoreSuspendedDaemonLocked(suspended, overrides = {}, options = {}) {
81
- if (!suspended.wasStopped || !suspended.wasFresh) {
93
+ if (!suspended.wasStopped) {
82
94
  return false;
83
95
  }
84
96
  const deps = dependencies(overrides);
97
+ if (!suspended.wasFresh) {
98
+ return Boolean(suspended.snapshot.info?.supervisor && await deps.startInstalledService());
99
+ }
85
100
  await deps.startDaemonLocked(suspended.port, true, {
86
101
  quiet: true,
87
- timeoutMs: options.timeoutMs,
102
+ // Restoring a background process must not consume the default five-minute sync budget.
103
+ timeoutMs: Math.min(options.timeoutMs ?? DAEMON_RESTORE_TIMEOUT_MS, DAEMON_RESTORE_TIMEOUT_MS),
88
104
  });
89
105
  return true;
90
106
  }
@@ -94,6 +110,7 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
94
110
  const remainingOperationTimeoutMs = (phase, action) => requireRemainingDeadlineMs(operationDeadlineAtMs, timeoutMs, phase, action);
95
111
  const remainingCleanupTimeoutMs = () => Math.max(1, remainingDeadlineMs(options.deadlineAtMs, timeoutMs));
96
112
  const releaseStartLock = await deps.acquireStartLock(remainingOperationTimeoutMs('daemon-lock-acquire', 'acquiring the daemon lifecycle lock'));
113
+ let releaseIntent;
97
114
  let suspended = null;
98
115
  let restoreAllowed = true;
99
116
  let cleanupError;
@@ -104,6 +121,7 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
104
121
  },
105
122
  };
106
123
  try {
124
+ releaseIntent = await deps.acquireIntentLock(remainingOperationTimeoutMs('daemon-intent-lock', 'acquiring exclusive intent'));
107
125
  suspended = await suspendDaemonForExclusiveAccessLocked(config, remainingOperationTimeoutMs('daemon-suspend', 'suspending the background daemon'), {
108
126
  deadlineAtMs: operationDeadlineAtMs,
109
127
  dependencies: deps,
@@ -119,6 +137,12 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
119
137
  outcome = { ok: false, error };
120
138
  }
121
139
  finally {
140
+ try {
141
+ await releaseIntent?.();
142
+ }
143
+ catch (error) {
144
+ cleanupError = error;
145
+ }
122
146
  if (suspended && restoreAllowed) {
123
147
  const suspendedDaemon = suspended;
124
148
  try {
@@ -21,3 +21,11 @@ export declare function removePid(expectedInstanceId?: string, options?: {
21
21
  export declare function isProcessRunning(pid: number): boolean;
22
22
  export declare function isLegacyDaemonProcess(pid: number, daemonUptimeMs: number, timeoutMs?: number): Promise<boolean>;
23
23
  export declare function acquireDaemonStartLock(timeoutMs?: number): Promise<() => Promise<void>>;
24
+ /** Present while `dino daemon stop` wants the Windows supervisor to stay down. */
25
+ export declare function getSupervisorStopMarkerPath(): string;
26
+ /**
27
+ * Held by a command that needs the PowerSync database in-process, from before
28
+ * it stops the daemon until it has closed the database. A daemon the service
29
+ * manager respawns in between waits for it (see waitForDaemonExclusiveIntent).
30
+ */
31
+ export declare function acquireDaemonExclusiveIntentLock(timeoutMs?: number): Promise<() => Promise<void>>;
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
6
6
  import { getDataDir } from '../config/paths.js';
7
7
  import { acquireExclusiveSqliteLease } from '../utils/sqliteLease.js';
8
+ import { SUPERVISOR_STOP_MARKER } from './supervisor.js';
8
9
  const DAEMON_START_LOCK_TIMEOUT_MS = 10_000;
9
10
  const LEGACY_DAEMON_UPTIME_TOLERANCE_MS = 60_000;
10
11
  const execFileAsync = promisify(execFile);
@@ -190,3 +191,22 @@ export async function acquireDaemonStartLock(timeoutMs = DAEMON_START_LOCK_TIMEO
190
191
  createBusyError: () => new Error('Timed out waiting for daemon start lock'),
191
192
  });
192
193
  }
194
+ /** Present while `dino daemon stop` wants the Windows supervisor to stay down. */
195
+ export function getSupervisorStopMarkerPath() {
196
+ return join(getDaemonDir(), SUPERVISOR_STOP_MARKER);
197
+ }
198
+ /**
199
+ * Held by a command that needs the PowerSync database in-process, from before
200
+ * it stops the daemon until it has closed the database. A daemon the service
201
+ * manager respawns in between waits for it (see waitForDaemonExclusiveIntent).
202
+ */
203
+ export async function acquireDaemonExclusiveIntentLock(timeoutMs = 30_000) {
204
+ ensureDaemonDir();
205
+ return acquireExclusiveSqliteLease({
206
+ lockPath: join(getDaemonDir(), 'daemon.exclusive.lock'),
207
+ timeoutMs,
208
+ maxWaitMs: timeoutMs,
209
+ pollIntervalMs: 50,
210
+ createBusyError: () => Object.assign(new Error('Timed out waiting for daemon exclusive intent lock'), { code: 'DAEMON_EXCLUSIVE_BUSY' }),
211
+ });
212
+ }
@@ -1,6 +1,7 @@
1
1
  import { sendDaemonRequest } from './client.js';
2
2
  import { ensureDaemonRunning } from './ensure.js';
3
3
  import { DinoxError } from '../utils/errors.js';
4
+ import { localCommandExecution, requestContext } from '../utils/requestContext.js';
4
5
  /**
5
6
  * Default online reads use the long-lived daemon as the canonical DB owner. The
6
7
  * process-local path is only for explicit modes that require opening the cache here:
@@ -17,7 +18,7 @@ export function shouldUseDaemon(globals) {
17
18
  * receives a thin sender bound to the daemon; `localRun` opens its own db/sync path.
18
19
  */
19
20
  export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
20
- if (!shouldUseDaemon(globals)) {
21
+ if (requestContext.getStore()?.daemon || localCommandExecution.getStore() || !shouldUseDaemon(globals)) {
21
22
  return localRun();
22
23
  }
23
24
  try {
@@ -1,5 +1,14 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
1
2
  import type { DaemonRequest, DaemonResponse } from './types.js';
3
+ import { type ConnectivityChange } from './connectivity.js';
4
+ import type { ResolvedDinoxConfig } from '../config/types.js';
5
+ import { connectPowerSync } from '../powersync/runtime.js';
6
+ import { acquireDaemonExclusiveIntentLock } from './pid.js';
7
+ /** EX_TEMPFAIL: the service manager restarts the daemon. */
8
+ export declare const DAEMON_RESTART_EXIT_CODE = 75;
2
9
  export declare class DaemonServer {
10
+ private readonly options;
11
+ private config;
3
12
  private db;
4
13
  private server;
5
14
  private startTime;
@@ -12,11 +21,46 @@ export declare class DaemonServer {
12
21
  private ownsArtifacts;
13
22
  private releaseOwner;
14
23
  private stopPromise;
24
+ private healthTimer;
25
+ private connectivityTimer;
26
+ private reconnect;
27
+ private reconnecting;
28
+ private syncUnconfirmedSince;
15
29
  private gateways;
30
+ constructor(options?: {
31
+ runtime?: {
32
+ db: AbstractPowerSyncDatabase;
33
+ config: ResolvedDinoxConfig;
34
+ };
35
+ acquireIntentLock?: typeof acquireDaemonExclusiveIntentLock;
36
+ connect?: typeof connectPowerSync;
37
+ onStopRequested?: (reason: string, details?: Record<string, unknown>) => void | Promise<void>;
38
+ });
16
39
  initialize(): Promise<void>;
17
40
  handleRequest(req: DaemonRequest): Promise<DaemonResponse>;
18
41
  private isCacheStale;
19
42
  start(port: number): Promise<void>;
20
43
  stop(): Promise<void>;
44
+ /**
45
+ * PowerSync reconnects on its own; a connection that stays failed for this
46
+ * long usually needs a fresh process (for example a rotated credential), so
47
+ * exit for the service manager to restart it.
48
+ */
49
+ private startHealthCheck;
50
+ private startConnectivityWatch;
51
+ /**
52
+ * After a wake from sleep or a network change the stream may be a dead
53
+ * connection that still reports "connected". Reconnect, and treat the cache
54
+ * as unconfirmed until a checkpoint completes on the new stream.
55
+ */
56
+ handleConnectivityChange(change: ConnectivityChange): Promise<void>;
57
+ /** The reconnect time while no checkpoint has completed since; null once confirmed. */
58
+ currentSyncUnconfirmedSince(): string | null;
21
59
  private stopInternal;
22
60
  }
61
+ /**
62
+ * Blocks until no exclusive command holds the intent lock. A respawned
63
+ * daemon must not take the database from the command that just stopped it;
64
+ * the lease is released by the OS if that command dies.
65
+ */
66
+ export declare function waitForDaemonExclusiveIntent(acquire?: typeof acquireDaemonExclusiveIntentLock): Promise<void>;