@dinoxx/dinox-cli 2.13.1 → 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 -1
  3. package/dist/commands/auth/index.js +18 -1
  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
package/dist/cli.js CHANGED
@@ -21,8 +21,9 @@ import { registerTagsCommand } from './commands/tags/index.js';
21
21
  import { registerTemplateCommands } from './commands/templates/index.js';
22
22
  import { registerTodoCommands } from './commands/todo/index.js';
23
23
  import { getPackageVersion } from './utils/version.js';
24
+ import { validateRequestId } from './requests/store.js';
24
25
  import { buildStructuredErrorPayload, DinoxError } from './utils/errors.js';
25
- import { clearRequestedStructuredOutputFormat, parseStructuredOutputFormatFromArgv, printError, resolveStructuredOutputFormat, setRequestedStructuredOutputFormat, } from './utils/output.js';
26
+ import { writeToStdout, writeToStderr, clearRequestedStructuredOutputFormat, parseStructuredOutputFormatFromArgv, printError, resolveStructuredOutputFormat, setRequestedStructuredOutputFormat, } from './utils/output.js';
26
27
  export function createCli() {
27
28
  clearRequestedStructuredOutputFormat();
28
29
  const program = new Command();
@@ -33,9 +34,10 @@ export function createCli() {
33
34
  .version(getPackageVersion())
34
35
  .showHelpAfterError()
35
36
  .configureOutput({
37
+ writeOut: writeToStdout,
36
38
  writeErr: (text) => {
37
39
  if (!structuredCommanderErrorWritten) {
38
- process.stderr.write(text);
40
+ writeToStderr(text);
39
41
  }
40
42
  },
41
43
  outputError: (text, write) => {
@@ -68,6 +70,7 @@ export function createCli() {
68
70
  .option('--offline', 'skip connect/sync and only use local cache')
69
71
  .option('--require-sync', 'fail if the local PowerSync cache cannot be confirmed fresh before reading')
70
72
  .option('--sync-timeout <ms>', 'override sync/connect timeout (milliseconds)')
73
+ .option('--request-id <id>', 'idempotency key: replays the stored result instead of running the command again (24h)', validateRequestId)
71
74
  .option('--verbose', 'enable verbose logging');
72
75
  program.hook('preAction', (_thisCommand, actionCommand) => {
73
76
  const options = actionCommand.optsWithGlobals?.() ?? {};
@@ -3,13 +3,14 @@ import { fetchUserInfoWithAuthorization } from '../../auth/userInfo.js';
3
3
  import { loadConfig, updateConfig } from '../../config/store.js';
4
4
  import { resolveConfig } from '../../config/resolve.js';
5
5
  import { restoreSuspendedDaemonLocked, suspendDaemonForExclusiveAccessLocked } from '../../daemon/lifecycle.js';
6
- import { acquireDaemonStartLock } from '../../daemon/pid.js';
6
+ import { acquireDaemonExclusiveIntentLock, acquireDaemonStartLock } from '../../daemon/pid.js';
7
7
  import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
8
8
  import type { CommandGroupDescriptor } from '../descriptor/types.js';
9
9
  import { printHumanReadableWarning } from '../../utils/output.js';
10
10
  export declare function releaseOwnerLeases(releases: ReadonlyArray<() => Promise<void>>): Promise<unknown[]>;
11
11
  type AuthLoginDependencies = {
12
12
  acquireDaemonStartLock: typeof acquireDaemonStartLock;
13
+ acquireDaemonIntentLock: typeof acquireDaemonExclusiveIntentLock;
13
14
  loadConfig: typeof loadConfig;
14
15
  resolveConfig: typeof resolveConfig;
15
16
  fetchCredentials: typeof fetchCredentialsWithAuthorization;
@@ -10,7 +10,7 @@ import { getDaemonInfo } from '../../daemon/client.js';
10
10
  import { restoreSuspendedDaemonLocked, suspendDaemonForExclusiveAccessLocked, } from '../../daemon/lifecycle.js';
11
11
  import { stopDaemonLocked } from '../../daemon/index.js';
12
12
  import { autoInstallDaemonService, describeDaemonServiceAutoInstall } from '../../daemon/autoInstall.js';
13
- import { acquireDaemonStartLock } from '../../daemon/pid.js';
13
+ import { acquireDaemonExclusiveIntentLock, acquireDaemonStartLock } from '../../daemon/pid.js';
14
14
  import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
15
15
  import { withExclusivePowerSync } from '../../powersync/exclusiveRuntime.js';
16
16
  import { acquirePowerSyncOwnerLease, getStatusSnapshot, } from '../../powersync/runtime.js';
@@ -22,6 +22,7 @@ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printHumanR
22
22
  import { redactAuthorization } from '../../utils/redact.js';
23
23
  import { withAsyncRelease } from '../../utils/asyncRelease.js';
24
24
  import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
25
+ import { assertStdinAvailable } from '../../utils/requestContext.js';
25
26
  const AUTH_LOGIN_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
26
27
  const AUTH_STATUS_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
27
28
  const MAX_STDIN_TOKEN_BYTES = 64 * 1024;
@@ -80,6 +81,7 @@ export async function releaseOwnerLeases(releases) {
80
81
  return errors;
81
82
  }
82
83
  async function readAuthTokenFromStdin() {
84
+ assertStdinAvailable();
83
85
  if (process.stdin.isTTY) {
84
86
  throw new DinoxError('--token-stdin requires piped stdin', {
85
87
  code: 'USAGE_ERROR',
@@ -100,6 +102,7 @@ async function readAuthTokenFromStdin() {
100
102
  }
101
103
  const authLoginDependencies = {
102
104
  acquireDaemonStartLock,
105
+ acquireDaemonIntentLock: acquireDaemonExclusiveIntentLock,
103
106
  loadConfig,
104
107
  resolveConfig,
105
108
  fetchCredentials: fetchCredentialsWithAuthorization,
@@ -117,6 +120,15 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
117
120
  const releaseStartLock = await dependencies.acquireDaemonStartLock(parseSyncTimeoutMs(syncTimeout) ?? 10_000);
118
121
  let previousConfig = null;
119
122
  let suspended = null;
123
+ // Held from stopping the daemon until the new login is saved, so a daemon
124
+ // the service manager respawns meanwhile starts with the new identity.
125
+ // Released before any restore, which waits for the daemon to come up.
126
+ let releaseIntentLock = null;
127
+ const releaseIntent = async () => {
128
+ const release = releaseIntentLock;
129
+ releaseIntentLock = null;
130
+ await release?.();
131
+ };
120
132
  let lifecycleLockReleaseError;
121
133
  let outcome;
122
134
  try {
@@ -138,6 +150,7 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
138
150
  });
139
151
  const verifiedConfig = mergeAuthIdentityIntoConfig(previousConfig, rawToken, identity);
140
152
  const verifiedResolved = dependencies.resolveConfig(verifiedConfig);
153
+ releaseIntentLock = await dependencies.acquireDaemonIntentLock(timeoutMs);
141
154
  suspended = await dependencies.suspendDaemon(previousResolved, timeoutMs, { forceStop: true });
142
155
  await dependencies.updateConfig((current) => {
143
156
  if (!persistedAuthStateMatches(current, previousConfig)) {
@@ -149,6 +162,7 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
149
162
  }
150
163
  return rotatePersistedAuthStateRevision(mergeAuthIdentityIntoConfig(current, rawToken, identity));
151
164
  }, { timeoutMs });
165
+ await releaseIntent();
152
166
  outcome = {
153
167
  ok: true,
154
168
  value: {
@@ -164,6 +178,9 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
164
178
  }
165
179
  catch (error) {
166
180
  outcome = { ok: false, error };
181
+ await releaseIntent().catch((releaseError) => {
182
+ dependencies.printWarning(`warning: failed to release daemon intent lock: ${authLoginErrorMessage(releaseError)}`);
183
+ });
167
184
  if (previousConfig && suspended) {
168
185
  try {
169
186
  const current = await dependencies.loadConfig();
@@ -5,7 +5,7 @@ import { resolveConfig } from '../config/resolve.js';
5
5
  import type { ResolvedDinoxConfig } from '../config/types.js';
6
6
  import { shutdownDaemonForExclusiveAccess } from '../daemon/client.js';
7
7
  import { restartDaemonLocked } from '../daemon/index.js';
8
- import { acquireDaemonStartLock } from '../daemon/pid.js';
8
+ import { acquireDaemonExclusiveIntentLock, acquireDaemonStartLock } from '../daemon/pid.js';
9
9
  import type { DaemonPidInfo } from '../daemon/pid.js';
10
10
  import { type DaemonPingResult } from '../daemon/types.js';
11
11
  import { connectPowerSync } from '../powersync/runtime.js';
@@ -89,6 +89,8 @@ type DoctorRuntime = {
89
89
  daemonBefore: DoctorDaemonSnapshot;
90
90
  daemonWasStopped: boolean;
91
91
  releaseDaemonStartLock: () => Promise<void>;
92
+ /** Held while doctor owns the database; see acquireDaemonExclusiveIntentLock. */
93
+ releaseIntentLock?: () => Promise<void>;
92
94
  daemonStartLockReleased: boolean;
93
95
  released: boolean;
94
96
  };
@@ -102,6 +104,7 @@ type DoctorPreparationDependencies = {
102
104
  resolveConfig: typeof resolveConfig;
103
105
  hydrateAuthIdentity: typeof hydrateResolvedAuthIdentity;
104
106
  acquireDaemonStartLock: typeof acquireDaemonStartLock;
107
+ acquireDaemonIntentLock: typeof acquireDaemonExclusiveIntentLock;
105
108
  captureDaemonSnapshot: typeof captureDaemonSnapshot;
106
109
  shutdownDaemon: typeof shutdownDaemonForExclusiveAccess;
107
110
  connectPowerSync: typeof connectPowerSync;
@@ -5,7 +5,7 @@ import { loadConfig } from '../config/store.js';
5
5
  import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
6
6
  import { getDaemonInfo, isDaemonFresh, shutdownDaemonForExclusiveAccess } from '../daemon/client.js';
7
7
  import { restartDaemonLocked } from '../daemon/index.js';
8
- import { acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, } from '../daemon/pid.js';
8
+ import { acquireDaemonExclusiveIntentLock, acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, } from '../daemon/pid.js';
9
9
  import { DEFAULT_DAEMON_PORT } from '../daemon/types.js';
10
10
  import { closePowerSyncAndReleaseOwner, connectPowerSync, getStatusSnapshot, waitForUploadQueueEmpty, } from '../powersync/runtime.js';
11
11
  import { inspectNoteTokenIndex, rebuildNoteTokenIndex, } from '../powersync/tokenIndex.js';
@@ -20,6 +20,7 @@ const doctorPreparationDependencies = {
20
20
  resolveConfig,
21
21
  hydrateAuthIdentity: hydrateResolvedAuthIdentity,
22
22
  acquireDaemonStartLock,
23
+ acquireDaemonIntentLock: acquireDaemonExclusiveIntentLock,
23
24
  captureDaemonSnapshot,
24
25
  shutdownDaemon: shutdownDaemonForExclusiveAccess,
25
26
  connectPowerSync,
@@ -39,6 +40,14 @@ function doctorTimeoutMs(globals, config) {
39
40
  export async function prepareDoctorRuntime(context, overrides = {}) {
40
41
  const dependencies = { ...doctorPreparationDependencies, ...overrides };
41
42
  const releaseDaemonStartLock = await dependencies.acquireDaemonStartLock(parseSyncTimeoutMs(context.globals.syncTimeout) ?? DEFAULT_DOCTOR_TIMEOUT_MS);
43
+ let releaseIntentLock;
44
+ const releaseIntent = async () => {
45
+ const release = releaseIntentLock;
46
+ releaseIntentLock = undefined;
47
+ await release?.().catch((error) => {
48
+ printHumanReadableWarning(`warning: failed to release daemon intent lock: ${errorMessage(error)}`);
49
+ });
50
+ };
42
51
  try {
43
52
  const rawConfig = await dependencies.loadConfig();
44
53
  const baseConfig = dependencies.resolveConfig(rawConfig);
@@ -56,6 +65,7 @@ export async function prepareDoctorRuntime(context, overrides = {}) {
56
65
  authError = errorMessage(error);
57
66
  }
58
67
  }
68
+ releaseIntentLock = await dependencies.acquireDaemonIntentLock(timeoutMs);
59
69
  const daemonBefore = await dependencies.captureDaemonSnapshot();
60
70
  const daemonDbPath = daemonBefore.info ? getPowerSyncDbPath(daemonBefore.info.userId) : null;
61
71
  const targetDbPath = getPowerSyncDbPath(config.auth.userId);
@@ -83,6 +93,7 @@ export async function prepareDoctorRuntime(context, overrides = {}) {
83
93
  daemonBefore,
84
94
  daemonWasStopped,
85
95
  releaseDaemonStartLock,
96
+ releaseIntentLock,
86
97
  daemonStartLockReleased: false,
87
98
  released: false,
88
99
  };
@@ -109,16 +120,19 @@ export async function prepareDoctorRuntime(context, overrides = {}) {
109
120
  daemonBefore,
110
121
  daemonWasStopped,
111
122
  releaseDaemonStartLock,
123
+ releaseIntentLock,
112
124
  daemonStartLockReleased: false,
113
125
  released: false,
114
126
  };
115
127
  }
116
128
  catch (error) {
129
+ await releaseIntent();
117
130
  await restoreStoppedDoctorDaemon(config, daemonBefore, daemonWasStopped, dependencies.restartDaemon);
118
131
  throw error;
119
132
  }
120
133
  }
121
134
  catch (error) {
135
+ await releaseIntent();
122
136
  await releaseDaemonStartLock().catch((releaseError) => {
123
137
  printHumanReadableWarning(`warning: failed to release daemon lifecycle lock: ${errorMessage(releaseError)}`);
124
138
  });
@@ -357,6 +371,11 @@ export async function releaseDoctorRuntime(runtime) {
357
371
  }
358
372
  await closePowerSyncAndReleaseOwner(runtime.db, runtime.releaseOwner);
359
373
  runtime.released = true;
374
+ const releaseIntentLock = runtime.releaseIntentLock;
375
+ runtime.releaseIntentLock = undefined;
376
+ await releaseIntentLock?.().catch((error) => {
377
+ printHumanReadableWarning(`warning: failed to release daemon intent lock: ${errorMessage(error)}`);
378
+ });
360
379
  }
361
380
  export async function releaseDoctorDaemonStartLock(runtime) {
362
381
  if (runtime.daemonStartLockReleased) {
@@ -4,6 +4,7 @@ import { registerCommandGroup } from '../descriptor/register.js';
4
4
  import { FORMAT_OPTIONS, OFFLINE_OPTIONS, REQUIRE_SYNC_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
5
5
  import { DinoxError } from '../../utils/errors.js';
6
6
  import { printSuccess } from '../../utils/output.js';
7
+ import { localCommandExecution, requestContext } from '../../utils/requestContext.js';
7
8
  import { commandStale, syncBeforeCommand, withCommandRuntime } from '../runtime.js';
8
9
  import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from './repo.js';
9
10
  const GRAPH_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...REQUIRE_SYNC_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
@@ -26,7 +27,7 @@ function parseDepth(value) {
26
27
  }
27
28
  async function runGraphCommand(context, onlineAction, offlineAction) {
28
29
  const globals = context.globals;
29
- if (Boolean(globals.offline) || Boolean(globals.requireSync)) {
30
+ if (requestContext.getStore()?.daemon || localCommandExecution.getStore() || Boolean(globals.offline) || Boolean(globals.requireSync)) {
30
31
  await withCommandRuntime(context, async (runtime) => {
31
32
  await syncBeforeCommand(runtime);
32
33
  printSuccess({
@@ -11,6 +11,7 @@ import { nowIsoUtc } from '../../utils/time.js';
11
11
  import { deriveImageDetailFromContentJson } from '../todo/contentModel.js';
12
12
  import { applyJsonPatchToDocument, buildNoteContentBlocks, buildNoteContentOutline, getNoteContentHash, parseStoredNoteDocument, projectNoteContent, } from './contentEngine.js';
13
13
  import { parseStoredTags } from './repo.js';
14
+ import { resolveUserPath } from '../../utils/requestContext.js';
14
15
  const READ_TOKEN_TTL_MS = 30 * 60 * 1000;
15
16
  const MAX_STORED_READ_TOKENS = 500;
16
17
  const TOKEN_STORE_FILE = 'note-content-read-tokens.json';
@@ -111,7 +112,7 @@ async function loadTokenStore(nowMs = Date.now()) {
111
112
  const filePath = tokenStorePath();
112
113
  let tokens = [];
113
114
  try {
114
- const raw = await fs.readFile(filePath, 'utf8');
115
+ const raw = await fs.readFile(resolveUserPath(filePath), 'utf8');
115
116
  const parsed = JSON.parse(raw);
116
117
  if (Array.isArray(parsed.tokens)) {
117
118
  tokens = parsed.tokens.filter((token) => typeof token?.token === 'string' &&
@@ -6,6 +6,7 @@ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printHumanR
6
6
  import { commandStale, } from '../runtime.js';
7
7
  import { getNoteExportItems, } from './repo.js';
8
8
  import { parseTargetNoteIds, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
9
+ import { resolveUserPath } from '../../utils/requestContext.js';
9
10
  export function parseExportFormat(value) {
10
11
  if (value === undefined || value === null || value === '') {
11
12
  return 'markdown';
@@ -107,7 +108,7 @@ async function pathExists(filePath) {
107
108
  }
108
109
  async function resolveOutputTargets(args) {
109
110
  const outputLooksLikeDirectory = /[\\/]$/.test(args.outputPath);
110
- const outputPath = path.resolve(args.outputPath);
111
+ const outputPath = resolveUserPath(args.outputPath);
111
112
  if (args.notes.length === 1) {
112
113
  if (outputLooksLikeDirectory) {
113
114
  return [{
@@ -160,7 +161,7 @@ export async function writeExportFiles(args) {
160
161
  }
161
162
  }
162
163
  if (args.notes.length > 1) {
163
- await fs.mkdir(path.resolve(args.outputPath), { recursive: true, mode: 0o700 });
164
+ await fs.mkdir(resolveUserPath(args.outputPath), { recursive: true, mode: 0o700 });
164
165
  }
165
166
  else {
166
167
  await fs.mkdir(path.dirname(targets[0].path), { recursive: true, mode: 0o700 });
@@ -43,4 +43,12 @@ export declare function waitForMutationUpload(runtime: CommandRuntime, options?:
43
43
  durability?: unknown;
44
44
  }): Promise<MutationUploadReceipt>;
45
45
  export declare function printStaleCacheWarning(runtime: CommandRuntime, message?: string): void;
46
+ /**
47
+ * Freshness on the daemon's long-lived stream. The server pushes every change,
48
+ * so a connected, error-free stream is current without a new checkpoint; an
49
+ * idle stream may not produce one before the timeout. After a reconnect (wake,
50
+ * network change) it may have been a dead connection, so then a checkpoint
51
+ * completed after the reconnect is required.
52
+ */
53
+ export declare function waitForDaemonCurrent(db: AbstractPowerSyncDatabase, _startedAt: string, timeoutMs: number): Promise<boolean>;
46
54
  export {};
@@ -8,6 +8,7 @@ import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
8
8
  import { createTimeoutSignal } from '../utils/abort.js';
9
9
  import { DinoxError } from '../utils/errors.js';
10
10
  import { printHumanReadableWarning } from '../utils/output.js';
11
+ import { requestContext } from '../utils/requestContext.js';
11
12
  import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
12
13
  import { parseWriteDurability } from './writeDurability.js';
13
14
  const PRE_SYNC_TIMEOUT_MS = 10_000;
@@ -44,6 +45,22 @@ export function resolveWriteDurabilityForRuntime(runtime, value) {
44
45
  export async function withCommandRuntime(source, run) {
45
46
  const startedAtIso = new Date().toISOString();
46
47
  const globals = resolveGlobals(source);
48
+ const daemon = requestContext.getStore()?.daemon;
49
+ if (daemon) {
50
+ const offline = Boolean(globals.offline);
51
+ const status = getStatusSnapshot(daemon.db);
52
+ const stale = offline || !status.connected || !status.hasSynced || Boolean(daemon.syncUnconfirmedSince?.());
53
+ return run({
54
+ db: daemon.db,
55
+ config: daemon.config,
56
+ rawConfig: {},
57
+ offline,
58
+ timeoutMs: parseSyncTimeoutMs(globals.syncTimeout) ?? daemon.config.sync.timeoutMs,
59
+ stale,
60
+ requireSync: Boolean(globals.requireSync),
61
+ sync: { firstSyncStale: stale, preSyncIdle: null, recentSync: null, gate: null, startedAtIso },
62
+ });
63
+ }
47
64
  const rawConfig = await loadConfig();
48
65
  const baseConfig = resolveConfig(rawConfig);
49
66
  const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? baseConfig.sync.timeoutMs;
@@ -99,7 +116,7 @@ export async function syncBeforeCommand(runtime, options = {}) {
99
116
  const deadlineAtMs = Date.now() + timeoutMs;
100
117
  const remainingTimeoutMs = () => Math.max(0, deadlineAtMs - Date.now());
101
118
  const recentSync = runtime.requireSync
102
- ? await waitForLastSyncedAfter(runtime.db, runtime.sync.startedAtIso, Math.max(1, remainingTimeoutMs()))
119
+ ? await (requestContext.getStore()?.daemon ? waitForDaemonCurrent : waitForLastSyncedAfter)(runtime.db, runtime.sync.startedAtIso, Math.max(1, remainingTimeoutMs()))
103
120
  : true;
104
121
  runtime.sync.recentSync = recentSync;
105
122
  const downloadIdle = await waitForDownloadIdle(runtime.db, Math.max(1, remainingTimeoutMs()));
@@ -178,3 +195,34 @@ export function printStaleCacheWarning(runtime, message = 'warning: sync timed o
178
195
  printHumanReadableWarning(message);
179
196
  }
180
197
  }
198
+ /**
199
+ * Freshness on the daemon's long-lived stream. The server pushes every change,
200
+ * so a connected, error-free stream is current without a new checkpoint; an
201
+ * idle stream may not produce one before the timeout. After a reconnect (wake,
202
+ * network change) it may have been a dead connection, so then a checkpoint
203
+ * completed after the reconnect is required.
204
+ */
205
+ export async function waitForDaemonCurrent(db, _startedAt, timeoutMs) {
206
+ const unconfirmedSince = requestContext.getStore()?.daemon?.syncUnconfirmedSince?.();
207
+ if (unconfirmedSince) {
208
+ return await waitForLastSyncedAfter(db, unconfirmedSince, timeoutMs)
209
+ && !getStatusSnapshot(db).dataFlowStatus.downloadError;
210
+ }
211
+ const current = () => {
212
+ const status = getStatusSnapshot(db);
213
+ return status.connected && status.hasSynced && !status.dataFlowStatus.downloadError;
214
+ };
215
+ if (current())
216
+ return true;
217
+ const timeout = createTimeoutSignal(timeoutMs);
218
+ try {
219
+ await db.waitForStatus(() => current(), timeout.signal);
220
+ return !timeout.signal.aborted && current();
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ finally {
226
+ timeout.cancel();
227
+ }
228
+ }
@@ -2,3 +2,4 @@ export declare const FORMAT_OPTIONS: string[];
2
2
  export declare const OFFLINE_OPTIONS: string[];
3
3
  export declare const REQUIRE_SYNC_OPTIONS: string[];
4
4
  export declare const SYNC_TIMEOUT_OPTIONS: string[];
5
+ export declare const REQUEST_ID_OPTIONS: string[];
@@ -2,3 +2,4 @@ export const FORMAT_OPTIONS = ['--format <yaml|json>', '--json'];
2
2
  export const OFFLINE_OPTIONS = ['--offline'];
3
3
  export const REQUIRE_SYNC_OPTIONS = ['--require-sync'];
4
4
  export const SYNC_TIMEOUT_OPTIONS = ['--sync-timeout <ms>'];
5
+ export const REQUEST_ID_OPTIONS = ['--request-id <id>'];
@@ -33,6 +33,7 @@ const GLOBAL_OPTIONS = [
33
33
  ['`--offline`', 'Skip sync, use local cache only'],
34
34
  ['`--require-sync`', 'Fail if the local PowerSync cache cannot be confirmed fresh before reading'],
35
35
  ['`--sync-timeout <ms>`', 'Override default 300000 ms (5 min) sync/connect timeout'],
36
+ ['`--request-id <id>`', 'Idempotency key for data commands: rerunning with the same id within 24h replays the stored result'],
36
37
  ['`--verbose`', 'Enable verbose logging'],
37
38
  ];
38
39
  function buildCommandSignature(schema) {
@@ -1,5 +1,6 @@
1
1
  import { authCommandGroup } from '../auth/index.js';
2
2
  import { boxCommandGroup } from '../boxes/index.js';
3
+ import { commandExecutionTarget } from '../../daemon/forward.js';
3
4
  import { commandSchemasFromGroupDescriptor } from '../descriptor/register.js';
4
5
  import { standaloneCommandSchemaFromDescriptor } from '../descriptor/register.js';
5
6
  import { configCommandGroup } from '../config/index.js';
@@ -14,6 +15,7 @@ import { promptCommandGroup } from '../prompt/index.js';
14
15
  import { propertyCommandGroup } from '../properties/index.js';
15
16
  import { savedViewCommandGroup } from '../savedViews/index.js';
16
17
  import { schemaCommandMetadata } from './commandMetadata.js';
18
+ import { REQUEST_ID_OPTIONS } from './globalOptions.js';
17
19
  import { skillsCommandGroup } from '../skills/index.js';
18
20
  import { syncCommandDescriptor } from '../sync.js';
19
21
  import { storageCommandGroup } from '../storage/index.js';
@@ -21,6 +23,12 @@ import { tagCommandGroup } from '../tags/index.js';
21
23
  import { templateCommandGroup } from '../templates/index.js';
22
24
  import { todoCommandGroup } from '../todo/index.js';
23
25
  import { updateCommandDescriptor } from '../update.js';
26
+ /** Every command that runs inside the daemon accepts an idempotency key. */
27
+ function withRequestId(schema) {
28
+ if (commandExecutionTarget(schema.path.split('.')) !== 'daemon')
29
+ return schema;
30
+ return { ...schema, globalOptions: [...schema.globalOptions, ...REQUEST_ID_OPTIONS] };
31
+ }
24
32
  export const COMMAND_SCHEMAS = [
25
33
  ...commandSchemasFromGroupDescriptor(authCommandGroup),
26
34
  ...commandSchemasFromGroupDescriptor(configCommandGroup),
@@ -43,7 +51,7 @@ export const COMMAND_SCHEMAS = [
43
51
  ...commandSchemasFromGroupDescriptor(noteCommandGroup),
44
52
  ...commandSchemasFromGroupDescriptor(graphCommandGroup),
45
53
  ...commandSchemasFromGroupDescriptor(todoCommandGroup),
46
- ];
54
+ ].map(withRequestId);
47
55
  const COMMAND_SCHEMA_MAP = new Map(COMMAND_SCHEMAS.map((schema) => [schema.path, schema]));
48
56
  export function listCommandSchemas() {
49
57
  return [...COMMAND_SCHEMAS];
@@ -1,6 +1,7 @@
1
1
  // Storage commands: inspect/test/upload orchestration with cleanup on partial failure.
2
2
  import fsp from 'node:fs/promises';
3
3
  import { DinoxError } from '../../../utils/errors.js';
4
+ import { resolveUserPath } from '../../../utils/requestContext.js';
4
5
  import { buildStorageTestPlan, buildStorageUploadPlan, getActiveCustomStorageConfigId, listStoredCustomStorageConfigs, resolveStorageSelection, } from './configs.js';
5
6
  import { createStorageCollisionError, createStorageUploadOutcomeUnknownError, defaultDeleteObject, defaultUploadBytes, defaultUploadObject, generateThumbnailBuffer, isObjectCollisionError, loadSharp, } from './remote.js';
6
7
  import { normalizeText, } from './shared.js';
@@ -8,7 +9,8 @@ import { persistUploadedResource } from './resources.js';
8
9
  import { buildStorageTarget, generateThumbnailKey } from './targets.js';
9
10
  export async function inspectStorageUpload(db, input, deps) {
10
11
  const statFile = deps?.statFile ?? fsp.stat;
11
- const filePath = input.filePath.trim();
12
+ const suppliedPath = input.filePath.trim();
13
+ const filePath = suppliedPath ? resolveUserPath(suppliedPath) : suppliedPath;
12
14
  if (!filePath) {
13
15
  throw new DinoxError('File path is required.');
14
16
  }
@@ -5,7 +5,7 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
5
5
  import { withExclusivePowerSync } from '../powersync/exclusiveRuntime.js';
6
6
  import type { CommandDescriptor, CommandExecutionContext } from './descriptor/types.js';
7
7
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess } from '../utils/output.js';
8
- import { getStatusSnapshot, waitForLastSyncedAfter } from '../powersync/runtime.js';
8
+ import { getStatusSnapshot, waitForLastSyncedAfter, waitForUploadQueueEmpty } from '../powersync/runtime.js';
9
9
  import { runSingleSyncPass } from '../powersync/syncPass.js';
10
10
  type SyncCommandDependencies = {
11
11
  loadConfig: typeof loadConfig;
@@ -15,6 +15,7 @@ type SyncCommandDependencies = {
15
15
  getRequestedStructuredOutputFormat: typeof getRequestedStructuredOutputFormat;
16
16
  getStatusSnapshot: typeof getStatusSnapshot;
17
17
  waitForLastSyncedAfter: typeof waitForLastSyncedAfter;
18
+ waitForUploadQueueEmpty: typeof waitForUploadQueueEmpty;
18
19
  runSingleSyncPass: typeof runSingleSyncPass;
19
20
  printHumanReadableLine: typeof printHumanReadableLine;
20
21
  printSuccess: typeof printSuccess;
@@ -1,12 +1,15 @@
1
+ import { getPowerSyncDbPath } from '../config/paths.js';
1
2
  import { loadConfig } from '../config/store.js';
2
3
  import { resolveConfig } from '../config/resolve.js';
3
4
  import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
4
5
  import { withExclusivePowerSync } from '../powersync/exclusiveRuntime.js';
6
+ import { requestContext } from '../utils/requestContext.js';
5
7
  import { registerStandaloneCommand } from './descriptor/register.js';
8
+ import { waitForDaemonCurrent } from './runtime.js';
6
9
  import { FORMAT_OPTIONS, SYNC_TIMEOUT_OPTIONS } from './schema/globalOptions.js';
7
10
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../utils/output.js';
8
11
  import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
9
- import { getStatusSnapshot, waitForLastSyncedAfter, } from '../powersync/runtime.js';
12
+ import { getStatusSnapshot, waitForLastSyncedAfter, waitForUploadQueueEmpty, } from '../powersync/runtime.js';
10
13
  import { runSingleSyncPass } from '../powersync/syncPass.js';
11
14
  import { createSyncRequiredError, evaluateSyncGate } from '../powersync/syncGate.js';
12
15
  const MAX_SYNC_CLEANUP_RESERVE_MS = 5_000;
@@ -18,6 +21,7 @@ const syncCommandDependencies = {
18
21
  getRequestedStructuredOutputFormat,
19
22
  getStatusSnapshot,
20
23
  waitForLastSyncedAfter,
24
+ waitForUploadQueueEmpty,
21
25
  runSingleSyncPass,
22
26
  printHumanReadableLine,
23
27
  printSuccess,
@@ -27,18 +31,26 @@ export async function executeSyncCommand(context, overrides = {}) {
27
31
  const dependencies = { ...syncCommandDependencies, ...overrides };
28
32
  const startedAtMs = dependencies.now();
29
33
  const startedAtIso = new Date(startedAtMs).toISOString();
30
- const baseConfig = dependencies.resolveConfig(await dependencies.loadConfig());
34
+ const daemon = requestContext.getStore()?.daemon;
35
+ const baseConfig = daemon?.config ?? dependencies.resolveConfig(await dependencies.loadConfig());
31
36
  const structuredOutput = dependencies.getRequestedStructuredOutputFormat() !== null;
32
37
  const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? baseConfig.sync.timeoutMs;
33
38
  const deadlineAtMs = startedAtMs + timeoutMs;
34
39
  const cleanupReserveMs = Math.min(MAX_SYNC_CLEANUP_RESERVE_MS, Math.max(1, Math.floor(timeoutMs * 0.2)));
35
40
  const operationDeadlineAtMs = deadlineAtMs - cleanupReserveMs;
36
41
  const remainingTimeoutMs = (reserveMs = 0) => Math.max(1, deadlineAtMs - dependencies.now() - reserveMs);
37
- const { config } = await dependencies.hydrateResolvedAuthIdentity(baseConfig, {
42
+ const { config } = daemon ? { config: daemon.config } : await dependencies.hydrateResolvedAuthIdentity(baseConfig, {
38
43
  timeoutMs: remainingTimeoutMs(),
39
44
  });
40
45
  const strict = Boolean(context.options.strict);
41
- const result = await dependencies.withExclusivePowerSync({
46
+ const withRuntime = daemon
47
+ ? async (_args, run) => {
48
+ const status = dependencies.getStatusSnapshot(daemon.db);
49
+ return run({ db: daemon.db, dbPath: getPowerSyncDbPath(config.auth.userId),
50
+ stale: !status.connected || !status.hasSynced, releaseOwner: async () => { } });
51
+ }
52
+ : dependencies.withExclusivePowerSync;
53
+ const result = await withRuntime({
42
54
  config,
43
55
  offline: false,
44
56
  timeoutMs: remainingTimeoutMs(),
@@ -49,9 +61,16 @@ export async function executeSyncCommand(context, overrides = {}) {
49
61
  cleanupFailureMode: 'throw',
50
62
  }, async ({ db, dbPath, stale }) => {
51
63
  const recentSync = strict
52
- ? await dependencies.waitForLastSyncedAfter(db, startedAtIso, remainingTimeoutMs(cleanupReserveMs))
64
+ ? await (daemon ? waitForDaemonCurrent : dependencies.waitForLastSyncedAfter)(db, startedAtIso, remainingTimeoutMs(cleanupReserveMs))
53
65
  : true;
54
66
  const syncPass = await dependencies.runSingleSyncPass(db, remainingTimeoutMs(cleanupReserveMs));
67
+ const uploadQueue = daemon
68
+ ? await dependencies.waitForUploadQueueEmpty(db, remainingTimeoutMs(cleanupReserveMs))
69
+ : null;
70
+ if (uploadQueue) {
71
+ syncPass.uploadIdle = uploadQueue.ok;
72
+ syncPass.idle = syncPass.downloadIdle && uploadQueue.ok;
73
+ }
55
74
  const status = dependencies.getStatusSnapshot(db);
56
75
  const gate = evaluateSyncGate({
57
76
  offline: false,
@@ -1,6 +1,7 @@
1
1
  import { request as httpRequest } from 'node:http';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { getDaemonSocketPath, isLegacyDaemonProcess, isProcessRunning, readPid, removePid, } from './pid.js';
4
+ import { getPackageVersion } from '../utils/version.js';
4
5
  import { remainingDeadlineMs } from '../utils/deadline.js';
5
6
  import { DinoxError, wireErrorCode } from '../utils/errors.js';
6
7
  const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 10_000;
@@ -34,6 +35,7 @@ export async function sendDaemonRequest(method, params, requestOptions = {}) {
34
35
  };
35
36
  return new Promise((resolve, reject) => {
36
37
  let settled = false;
38
+ let connected = false;
37
39
  let deadlineTimer;
38
40
  const finalize = (error, value) => {
39
41
  if (settled)
@@ -43,6 +45,8 @@ export async function sendDaemonRequest(method, params, requestOptions = {}) {
43
45
  clearTimeout(deadlineTimer);
44
46
  }
45
47
  if (error) {
48
+ if (error instanceof Error)
49
+ Object.assign(error, { requestWritten: connected });
46
50
  reject(error);
47
51
  return;
48
52
  }
@@ -66,7 +70,7 @@ export async function sendDaemonRequest(method, params, requestOptions = {}) {
66
70
  let totalBytes = 0;
67
71
  res.on('data', (chunk) => {
68
72
  totalBytes += chunk.length;
69
- if (totalBytes > MAX_DAEMON_RESPONSE_BYTES) {
73
+ if (method !== 'command.execute' && totalBytes > MAX_DAEMON_RESPONSE_BYTES) {
70
74
  req.destroy(new Error('Daemon response too large'));
71
75
  return;
72
76
  }
@@ -92,10 +96,19 @@ export async function sendDaemonRequest(method, params, requestOptions = {}) {
92
96
  }
93
97
  });
94
98
  });
99
+ req.on('socket', socket => {
100
+ if (!socket.connecting)
101
+ connected = true;
102
+ socket.once('connect', () => { connected = true; });
103
+ });
95
104
  req.on('timeout', () => {
96
105
  req.destroy(new Error('Daemon request timed out'));
97
106
  });
98
107
  req.on('error', (error) => finalize(error));
108
+ req.on('response', res => {
109
+ res.on('aborted', () => finalize(new Error('Daemon response interrupted')));
110
+ res.on('error', error => finalize(error));
111
+ });
99
112
  deadlineTimer = setTimeout(() => {
100
113
  req.destroy(new Error('Daemon request timed out'));
101
114
  }, timeoutMs);
@@ -282,6 +295,16 @@ export async function waitForFreshDaemon(expectedVersion, expectedUserId, expect
282
295
  }
283
296
  }
284
297
  }
298
+ /** Daemon log context for a stop request; never carries credentials. */
299
+ function redactRequesterCommand(argv) {
300
+ return argv.join(' ')
301
+ .replace(/(auth\s+login)\b[\s\S]*/i, '$1 [REDACTED]')
302
+ .replace(/((?:--)?[\w-]*(?:token|authorization|secret|password|api[-_]?key)[\w-]*[=\s]+)(?:Bearer\s+)?[^\s]+/gi, '$1[REDACTED]')
303
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [REDACTED]')
304
+ .replace(/\beyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){1,2}\b/g, '[REDACTED]')
305
+ .replace(/\b[A-Za-z0-9_-]{32,}\b/g, '[REDACTED]')
306
+ .slice(0, 200);
307
+ }
285
308
  /** Request shutdown of one exact daemon instance and reject if the socket changed owners. */
286
309
  export async function requestDaemonShutdown(expectedInstanceId, timeoutMs = 2_000) {
287
310
  if (!expectedInstanceId) {
@@ -290,7 +313,14 @@ export async function requestDaemonShutdown(expectedInstanceId, timeoutMs = 2_00
290
313
  recoverable: true,
291
314
  });
292
315
  }
293
- const result = await sendDaemonRequest('shutdown', { expectedInstanceId }, { timeoutMs });
316
+ const result = await sendDaemonRequest('shutdown', {
317
+ expectedInstanceId,
318
+ requester: {
319
+ pid: process.pid,
320
+ version: getPackageVersion(),
321
+ command: redactRequesterCommand(process.argv.slice(2)),
322
+ },
323
+ }, { timeoutMs });
294
324
  if (!result
295
325
  || typeof result !== 'object'
296
326
  || result.stopping !== true