@dinoxx/dinox-cli 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -681,13 +681,16 @@ dino auth logout --clear-local-db
681
681
 
682
682
  ```bash
683
683
  dino sync
684
- dino sync --sync-timeout 600000
684
+ dino sync --sync-timeout 20000
685
685
  ```
686
686
 
687
687
  用途说明:
688
688
 
689
689
  - 主动执行一次连接与同步
690
690
  - 首次使用、网络恢复后、或想刷新本地缓存时最常用
691
+ - `--sync-timeout` 是包含 checkpoint、下载、本地索引和前台运行时准备在内的总预算;AI/CI 外层命令超时应至少比它多几秒
692
+ - 上传仍在进行不会阻塞下载新鲜度。判断成功应看 `stale`、`downloadIdle` 和 `gate`,不能只看兼容字段 `idle`
693
+ - 结构化成功结果会在数据库关闭、owner lease 释放和 daemon 恢复完成后输出
691
694
 
692
695
  ### 6.4 `note`:笔记管理
693
696
 
@@ -2,9 +2,10 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
2
2
  import { loadConfig } from '../config/store.js';
3
3
  import { resolveConfig } from '../config/resolve.js';
4
4
  import { withExclusivePowerSync } from '../powersync/exclusiveRuntime.js';
5
- import { getStatusSnapshot, waitForIdleDataFlow, waitForLastSyncedAfter, waitForUploadQueueEmpty, } from '../powersync/runtime.js';
5
+ import { getStatusSnapshot, waitForDownloadIdle, waitForLastSyncedAfter, waitForUploadQueueEmpty, } from '../powersync/runtime.js';
6
6
  import { createSyncRequiredError, evaluateSyncGate } from '../powersync/syncGate.js';
7
7
  import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
8
+ import { createTimeoutSignal } from '../utils/abort.js';
8
9
  import { DinoxError } from '../utils/errors.js';
9
10
  import { printHumanReadableWarning } from '../utils/output.js';
10
11
  import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
@@ -94,20 +95,30 @@ export async function syncBeforeCommand(runtime, options = {}) {
94
95
  }
95
96
  return;
96
97
  }
97
- const idle = await waitForIdleDataFlow(runtime.db, Math.min(runtime.timeoutMs, options.timeoutMs ?? PRE_SYNC_TIMEOUT_MS));
98
- runtime.sync.preSyncIdle = idle;
98
+ const timeoutMs = Math.min(runtime.timeoutMs, options.timeoutMs ?? PRE_SYNC_TIMEOUT_MS);
99
+ const deadlineAtMs = Date.now() + timeoutMs;
100
+ const remainingTimeoutMs = () => Math.max(0, deadlineAtMs - Date.now());
99
101
  const recentSync = runtime.requireSync
100
- ? await waitForLastSyncedAfter(runtime.db, runtime.sync.startedAtIso, Math.min(runtime.timeoutMs, options.timeoutMs ?? PRE_SYNC_TIMEOUT_MS))
102
+ ? await waitForLastSyncedAfter(runtime.db, runtime.sync.startedAtIso, Math.max(1, remainingTimeoutMs()))
101
103
  : true;
102
104
  runtime.sync.recentSync = recentSync;
105
+ const downloadIdle = await waitForDownloadIdle(runtime.db, Math.max(1, remainingTimeoutMs()));
106
+ runtime.sync.preSyncIdle = downloadIdle;
107
+ let indexComplete;
103
108
  if (options.syncTokenIndex) {
104
- await syncNoteTokenIndex(runtime.db);
109
+ const indexTimeoutMs = remainingTimeoutMs();
110
+ const timeout = indexTimeoutMs > 0 ? createTimeoutSignal(indexTimeoutMs) : null;
111
+ const tokenIndex = await syncNoteTokenIndex(runtime.db, {
112
+ signal: timeout?.signal ?? AbortSignal.abort(),
113
+ }).finally(() => timeout?.cancel());
114
+ indexComplete = tokenIndex.complete;
105
115
  }
106
116
  const status = getStatusSnapshot(runtime.db);
107
117
  const gate = evaluateSyncGate({
108
118
  offline: runtime.offline,
109
119
  firstSyncStale: runtime.sync.firstSyncStale,
110
- idle,
120
+ idle: downloadIdle,
121
+ indexComplete,
111
122
  recentSync,
112
123
  status,
113
124
  });
@@ -1,4 +1,26 @@
1
1
  import { Command } from 'commander';
2
- import type { CommandDescriptor } from './descriptor/types.js';
2
+ import { loadConfig } from '../config/store.js';
3
+ import { resolveConfig } from '../config/resolve.js';
4
+ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
5
+ import { withExclusivePowerSync } from '../powersync/exclusiveRuntime.js';
6
+ import type { CommandDescriptor, CommandExecutionContext } from './descriptor/types.js';
7
+ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess } from '../utils/output.js';
8
+ import { getStatusSnapshot, waitForLastSyncedAfter } from '../powersync/runtime.js';
9
+ import { runSingleSyncPass } from '../powersync/syncPass.js';
10
+ type SyncCommandDependencies = {
11
+ loadConfig: typeof loadConfig;
12
+ resolveConfig: typeof resolveConfig;
13
+ hydrateResolvedAuthIdentity: typeof hydrateResolvedAuthIdentity;
14
+ withExclusivePowerSync: typeof withExclusivePowerSync;
15
+ getRequestedStructuredOutputFormat: typeof getRequestedStructuredOutputFormat;
16
+ getStatusSnapshot: typeof getStatusSnapshot;
17
+ waitForLastSyncedAfter: typeof waitForLastSyncedAfter;
18
+ runSingleSyncPass: typeof runSingleSyncPass;
19
+ printHumanReadableLine: typeof printHumanReadableLine;
20
+ printSuccess: typeof printSuccess;
21
+ now: () => number;
22
+ };
23
+ export declare function executeSyncCommand(context: CommandExecutionContext, overrides?: Partial<SyncCommandDependencies>): Promise<void>;
3
24
  export declare const syncCommandDescriptor: CommandDescriptor;
4
25
  export declare function registerSyncCommand(program: Command): void;
26
+ export {};
@@ -9,13 +9,108 @@ import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
9
9
  import { getStatusSnapshot, waitForLastSyncedAfter, } from '../powersync/runtime.js';
10
10
  import { runSingleSyncPass } from '../powersync/syncPass.js';
11
11
  import { createSyncRequiredError, evaluateSyncGate } from '../powersync/syncGate.js';
12
+ const MAX_SYNC_CLEANUP_RESERVE_MS = 5_000;
13
+ const syncCommandDependencies = {
14
+ loadConfig,
15
+ resolveConfig,
16
+ hydrateResolvedAuthIdentity,
17
+ withExclusivePowerSync,
18
+ getRequestedStructuredOutputFormat,
19
+ getStatusSnapshot,
20
+ waitForLastSyncedAfter,
21
+ runSingleSyncPass,
22
+ printHumanReadableLine,
23
+ printSuccess,
24
+ now: Date.now,
25
+ };
26
+ export async function executeSyncCommand(context, overrides = {}) {
27
+ const dependencies = { ...syncCommandDependencies, ...overrides };
28
+ const startedAtMs = dependencies.now();
29
+ const startedAtIso = new Date(startedAtMs).toISOString();
30
+ const baseConfig = dependencies.resolveConfig(await dependencies.loadConfig());
31
+ const structuredOutput = dependencies.getRequestedStructuredOutputFormat() !== null;
32
+ const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? baseConfig.sync.timeoutMs;
33
+ const deadlineAtMs = startedAtMs + timeoutMs;
34
+ const cleanupReserveMs = Math.min(MAX_SYNC_CLEANUP_RESERVE_MS, Math.max(1, Math.floor(timeoutMs * 0.2)));
35
+ const operationDeadlineAtMs = deadlineAtMs - cleanupReserveMs;
36
+ const remainingTimeoutMs = (reserveMs = 0) => Math.max(1, deadlineAtMs - dependencies.now() - reserveMs);
37
+ const { config } = await dependencies.hydrateResolvedAuthIdentity(baseConfig, {
38
+ timeoutMs: remainingTimeoutMs(),
39
+ });
40
+ const strict = Boolean(context.options.strict);
41
+ const result = await dependencies.withExclusivePowerSync({
42
+ config,
43
+ offline: false,
44
+ timeoutMs: remainingTimeoutMs(),
45
+ deadlineAtMs: operationDeadlineAtMs,
46
+ waitForInitialSync: false,
47
+ expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
48
+ cleanupFailureMode: 'throw',
49
+ }, async ({ db, dbPath, stale }) => {
50
+ const recentSync = strict
51
+ ? await dependencies.waitForLastSyncedAfter(db, startedAtIso, remainingTimeoutMs(cleanupReserveMs))
52
+ : true;
53
+ const syncPass = await dependencies.runSingleSyncPass(db, remainingTimeoutMs(cleanupReserveMs));
54
+ const status = dependencies.getStatusSnapshot(db);
55
+ const gate = evaluateSyncGate({
56
+ offline: false,
57
+ firstSyncStale: stale,
58
+ idle: syncPass.downloadIdle,
59
+ indexComplete: syncPass.tokenIndex.complete,
60
+ recentSync,
61
+ status,
62
+ });
63
+ const payload = {
64
+ ok: true,
65
+ dbPath,
66
+ stale: !gate.ok,
67
+ idle: syncPass.idle,
68
+ downloadIdle: syncPass.downloadIdle,
69
+ uploadIdle: syncPass.uploadIdle,
70
+ strict,
71
+ gate,
72
+ uploadEnabled: Boolean(config.powersync.uploadBaseUrl),
73
+ tokenIndex: syncPass.tokenIndex,
74
+ status,
75
+ };
76
+ if (strict && !gate.ok) {
77
+ throw createSyncRequiredError(gate, status);
78
+ }
79
+ return payload;
80
+ });
81
+ const payload = {
82
+ ...result,
83
+ elapsedMs: Math.max(0, dependencies.now() - startedAtMs),
84
+ };
85
+ if (structuredOutput) {
86
+ dependencies.printSuccess(payload);
87
+ return;
88
+ }
89
+ dependencies.printHumanReadableLine(`db: ${payload.dbPath}`);
90
+ if (payload.status.lastSyncedAt) {
91
+ dependencies.printHumanReadableLine(`lastSyncedAt: ${payload.status.lastSyncedAt}`);
92
+ }
93
+ if (payload.stale) {
94
+ dependencies.printHumanReadableLine('warning: sync freshness or local index could not be fully verified');
95
+ }
96
+ if (!payload.downloadIdle) {
97
+ dependencies.printHumanReadableLine('warning: download flow did not settle before timeout');
98
+ }
99
+ if (!payload.uploadIdle) {
100
+ dependencies.printHumanReadableLine('warning: uploads are still active; downloaded data may already be current');
101
+ }
102
+ if (!payload.tokenIndex.complete) {
103
+ dependencies.printHumanReadableLine('warning: local token index did not finish before timeout');
104
+ }
105
+ dependencies.printHumanReadableLine(`tokenIndex: complete=${payload.tokenIndex.complete} scanned=${payload.tokenIndex.scanned} reindexed=${payload.tokenIndex.reindexed} skipped=${payload.tokenIndex.skipped} removed=${payload.tokenIndex.removed} ftsRowsUpserted=${payload.tokenIndex.ftsRowsUpserted}`);
106
+ }
12
107
  export const syncCommandDescriptor = {
13
108
  name: 'sync',
14
109
  description: 'Connect and synchronize the local PowerSync database',
15
110
  options: [
16
111
  {
17
112
  flags: '--strict',
18
- description: 'Fail unless connected, a new checkpoint completes, data flow is idle, and no download error exists',
113
+ description: 'Fail unless connected, a current checkpoint completes, downloads settle, and the local index finishes',
19
114
  type: 'boolean',
20
115
  },
21
116
  ],
@@ -27,65 +122,11 @@ export const syncCommandDescriptor = {
27
122
  output: {
28
123
  type: 'structured',
29
124
  summary: 'Sync result with connection status and token index counters',
30
- fields: ['dbPath', 'stale', 'idle', 'strict', 'gate', 'uploadEnabled', 'tokenIndex', 'status'],
125
+ fields: ['dbPath', 'stale', 'idle', 'downloadIdle', 'uploadIdle', 'strict', 'gate', 'uploadEnabled', 'tokenIndex', 'status', 'elapsedMs'],
31
126
  },
32
- examples: ['dino sync --format json', 'dino sync --strict --sync-timeout 600000 --format json'],
33
- },
34
- action: async (context) => {
35
- const startedAtIso = new Date().toISOString();
36
- const baseConfig = resolveConfig(await loadConfig());
37
- const structuredOutput = getRequestedStructuredOutputFormat() !== null;
38
- const timeoutMs = parseSyncTimeoutMs(context.globals.syncTimeout) ?? baseConfig.sync.timeoutMs;
39
- const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs });
40
- await withExclusivePowerSync({
41
- config,
42
- offline: false,
43
- timeoutMs,
44
- expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
45
- }, async ({ db, dbPath, stale }) => {
46
- const syncPass = await runSingleSyncPass(db, timeoutMs);
47
- const recentSync = context.options.strict
48
- ? await waitForLastSyncedAfter(db, startedAtIso, timeoutMs)
49
- : true;
50
- const status = getStatusSnapshot(db);
51
- const gate = evaluateSyncGate({
52
- offline: false,
53
- firstSyncStale: stale,
54
- idle: syncPass.idle,
55
- recentSync,
56
- status,
57
- });
58
- const payload = {
59
- ok: true,
60
- dbPath,
61
- stale: !gate.ok,
62
- idle: syncPass.idle,
63
- strict: Boolean(context.options.strict),
64
- gate,
65
- uploadEnabled: Boolean(config.powersync.uploadBaseUrl),
66
- tokenIndex: syncPass.tokenIndex,
67
- status,
68
- };
69
- if (context.options.strict && !gate.ok) {
70
- throw createSyncRequiredError(gate, status);
71
- }
72
- if (structuredOutput) {
73
- printSuccess(payload);
74
- return;
75
- }
76
- printHumanReadableLine(`db: ${payload.dbPath}`);
77
- if (payload.status.lastSyncedAt) {
78
- printHumanReadableLine(`lastSyncedAt: ${payload.status.lastSyncedAt}`);
79
- }
80
- if (payload.stale) {
81
- printHumanReadableLine('warning: sync timed out; local cache may be stale');
82
- }
83
- if (!payload.idle) {
84
- printHumanReadableLine('warning: data flow did not become idle before timeout');
85
- }
86
- printHumanReadableLine(`tokenIndex: scanned=${payload.tokenIndex.scanned} reindexed=${payload.tokenIndex.reindexed} skipped=${payload.tokenIndex.skipped} removed=${payload.tokenIndex.removed} ftsRowsUpserted=${payload.tokenIndex.ftsRowsUpserted}`);
87
- });
127
+ examples: ['dino sync --sync-timeout 20000 --format json', 'dino sync --strict --sync-timeout 20000 --format json'],
88
128
  },
129
+ action: executeSyncCommand,
89
130
  };
90
131
  export function registerSyncCommand(program) {
91
132
  registerStandaloneCommand(program, syncCommandDescriptor);
@@ -38,5 +38,7 @@ export declare function suspendDaemonForExclusiveAccessLocked(config: ResolvedDi
38
38
  }): Promise<SuspendedDaemon>;
39
39
  /** Caller must hold the daemon start lock. */
40
40
  export declare function restoreSuspendedDaemonLocked(suspended: SuspendedDaemon, overrides?: Partial<DaemonLifecycleDependencies>): Promise<boolean>;
41
- export declare function withDaemonExclusiveAccess<T>(config: ResolvedDinoxConfig, timeoutMs: number, run: (control: DaemonExclusiveAccessControl) => Promise<T>, overrides?: Partial<DaemonLifecycleDependencies>): Promise<T>;
41
+ export declare function withDaemonExclusiveAccess<T>(config: ResolvedDinoxConfig, timeoutMs: number, run: (control: DaemonExclusiveAccessControl) => Promise<T>, overrides?: Partial<DaemonLifecycleDependencies>, options?: {
42
+ deadlineAtMs?: number;
43
+ }): Promise<T>;
42
44
  export {};
@@ -67,9 +67,12 @@ export async function restoreSuspendedDaemonLocked(suspended, overrides = {}) {
67
67
  await deps.startDaemonLocked(suspended.port, true, { quiet: true });
68
68
  return true;
69
69
  }
70
- export async function withDaemonExclusiveAccess(config, timeoutMs, run, overrides = {}) {
70
+ export async function withDaemonExclusiveAccess(config, timeoutMs, run, overrides = {}, options = {}) {
71
71
  const deps = dependencies(overrides);
72
- const releaseStartLock = await deps.acquireStartLock(timeoutMs);
72
+ const remainingTimeoutMs = () => options.deadlineAtMs === undefined
73
+ ? timeoutMs
74
+ : Math.max(1, Math.min(timeoutMs, options.deadlineAtMs - Date.now()));
75
+ const releaseStartLock = await deps.acquireStartLock(remainingTimeoutMs());
73
76
  let suspended = null;
74
77
  let restoreAllowed = true;
75
78
  let outcome;
@@ -79,7 +82,7 @@ export async function withDaemonExclusiveAccess(config, timeoutMs, run, override
79
82
  },
80
83
  };
81
84
  try {
82
- suspended = await suspendDaemonForExclusiveAccessLocked(config, timeoutMs, {
85
+ suspended = await suspendDaemonForExclusiveAccessLocked(config, remainingTimeoutMs(), {
83
86
  dependencies: deps,
84
87
  });
85
88
  try {
@@ -12,6 +12,7 @@ export declare function withExclusivePowerSync<T>(args: {
12
12
  config: ResolvedDinoxConfig;
13
13
  offline: boolean;
14
14
  timeoutMs: number;
15
+ deadlineAtMs?: number;
15
16
  waitForInitialSync?: boolean;
16
17
  expectedTokenFingerprint?: string | null;
17
18
  cleanupFailureMode?: 'warn' | 'throw';
@@ -12,9 +12,15 @@ function errorMessage(error) {
12
12
  }
13
13
  export async function withExclusivePowerSync(args, run, overrides = {}) {
14
14
  const dependencies = { ...exclusivePowerSyncDependencies, ...overrides };
15
- const { cleanupFailureMode, ...connectArgs } = args;
16
- return dependencies.withDaemonExclusiveAccess(args.config, args.timeoutMs, async (daemonLifecycle) => {
17
- const connected = await dependencies.connectPowerSync(connectArgs);
15
+ const { cleanupFailureMode, deadlineAtMs, ...connectArgs } = args;
16
+ const remainingTimeoutMs = () => deadlineAtMs === undefined
17
+ ? args.timeoutMs
18
+ : Math.max(1, Math.min(args.timeoutMs, deadlineAtMs - Date.now()));
19
+ return dependencies.withDaemonExclusiveAccess(args.config, remainingTimeoutMs(), async (daemonLifecycle) => {
20
+ const connected = await dependencies.connectPowerSync({
21
+ ...connectArgs,
22
+ timeoutMs: remainingTimeoutMs(),
23
+ });
18
24
  let outcome;
19
25
  try {
20
26
  outcome = { ok: true, value: await run(connected) };
@@ -38,5 +44,5 @@ export async function withExclusivePowerSync(args, run, overrides = {}) {
38
44
  throw cleanupError;
39
45
  }
40
46
  return outcome.value;
41
- });
47
+ }, {}, { deadlineAtMs });
42
48
  }
@@ -39,6 +39,7 @@ export declare function connectPowerSync(args: {
39
39
  expectedTokenFingerprint?: string | null;
40
40
  }): Promise<ConnectResult>;
41
41
  export declare function waitForIdleDataFlow(db: AbstractPowerSyncDatabase, timeoutMs: number): Promise<boolean>;
42
+ export declare function waitForDownloadIdle(db: AbstractPowerSyncDatabase, timeoutMs: number): Promise<boolean>;
42
43
  export declare function waitForLastSyncedAfter(db: AbstractPowerSyncDatabase, startedAtIso: string, timeoutMs: number): Promise<boolean>;
43
44
  export type UploadQueueWaitResult = {
44
45
  ok: boolean;
@@ -239,13 +239,33 @@ export async function waitForIdleDataFlow(db, timeoutMs) {
239
239
  cancel();
240
240
  }
241
241
  }
242
+ export async function waitForDownloadIdle(db, timeoutMs) {
243
+ const { signal, cancel } = createTimeoutSignal(timeoutMs);
244
+ try {
245
+ await db.waitForStatus(status => {
246
+ const flow = status.dataFlowStatus;
247
+ return Boolean(status.connected) && Boolean(status.hasSynced) && !flow.downloading;
248
+ }, signal);
249
+ return !signal.aborted;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ finally {
255
+ cancel();
256
+ }
257
+ }
242
258
  export async function waitForLastSyncedAfter(db, startedAtIso, timeoutMs) {
243
259
  const startedAtMs = Date.parse(startedAtIso);
244
260
  if (!Number.isFinite(startedAtMs)) {
245
261
  return false;
246
262
  }
263
+ // PowerSync reports checkpoint timestamps with whole-second precision.
264
+ const startedAtCheckpointPrecisionMs = Math.floor(startedAtMs / 1_000) * 1_000;
247
265
  const status = getStatusSnapshot(db);
248
- if (status.connected && status.lastSyncedAt && Date.parse(status.lastSyncedAt) >= startedAtMs) {
266
+ if (status.connected
267
+ && status.lastSyncedAt
268
+ && Date.parse(status.lastSyncedAt) >= startedAtCheckpointPrecisionMs) {
249
269
  return true;
250
270
  }
251
271
  const { signal, cancel } = createTimeoutSignal(timeoutMs);
@@ -253,7 +273,9 @@ export async function waitForLastSyncedAfter(db, startedAtIso, timeoutMs) {
253
273
  await db.waitForStatus((nextStatus) => {
254
274
  const value = nextStatus.lastSyncedAt;
255
275
  const lastSyncedAt = value instanceof Date ? value.toISOString() : value ?? null;
256
- return Boolean(nextStatus.connected && lastSyncedAt && Date.parse(lastSyncedAt) >= startedAtMs);
276
+ return Boolean(nextStatus.connected
277
+ && lastSyncedAt
278
+ && Date.parse(lastSyncedAt) >= startedAtCheckpointPrecisionMs);
257
279
  }, signal);
258
280
  return !signal.aborted;
259
281
  }
@@ -1,6 +1,6 @@
1
1
  import type { SyncStatusOptions } from '@powersync/common';
2
2
  import { DinoxError } from '../utils/errors.js';
3
- export type SyncGateFailureReason = 'offline' | 'first-sync-timeout' | 'checkpoint-timeout' | 'data-flow-timeout' | 'not-connected' | 'not-synced' | 'download-error';
3
+ export type SyncGateFailureReason = 'offline' | 'first-sync-timeout' | 'checkpoint-timeout' | 'data-flow-timeout' | 'index-timeout' | 'not-connected' | 'not-synced' | 'download-error';
4
4
  export type SyncGateResult = {
5
5
  ok: boolean;
6
6
  reasons: SyncGateFailureReason[];
@@ -9,6 +9,7 @@ export type SyncGateInput = {
9
9
  offline: boolean;
10
10
  firstSyncStale: boolean;
11
11
  idle: boolean;
12
+ indexComplete?: boolean;
12
13
  recentSync?: boolean;
13
14
  status: {
14
15
  connected: boolean;
@@ -16,6 +16,9 @@ export function evaluateSyncGate(input) {
16
16
  if (!input.idle) {
17
17
  reasons.push('data-flow-timeout');
18
18
  }
19
+ if (input.indexComplete === false) {
20
+ reasons.push('index-timeout');
21
+ }
19
22
  if (!input.status.connected) {
20
23
  reasons.push('not-connected');
21
24
  }
@@ -37,6 +40,6 @@ export function createSyncRequiredError(gate, details) {
37
40
  reasons: gate.reasons,
38
41
  ...(details !== undefined ? { status: details } : {}),
39
42
  },
40
- suggestion: 'Run `dino sync --strict --sync-timeout 600000 --format json`, then retry the command.',
43
+ suggestion: 'Run `dino sync --strict --sync-timeout 20000 --format json`, then retry the command.',
41
44
  });
42
45
  }
@@ -2,6 +2,8 @@ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
2
  import { type NoteTokenIndexResult } from './tokenIndex.js';
3
3
  export type SyncPassResult = {
4
4
  idle: boolean;
5
+ downloadIdle: boolean;
6
+ uploadIdle: boolean;
5
7
  tokenIndex: NoteTokenIndexResult;
6
8
  };
7
9
  export declare function runSingleSyncPass(db: AbstractPowerSyncDatabase, timeoutMs: number): Promise<SyncPassResult>;
@@ -1,10 +1,21 @@
1
- import { waitForIdleDataFlow } from './runtime.js';
1
+ import { createTimeoutSignal } from '../utils/abort.js';
2
+ import { getStatusSnapshot, waitForDownloadIdle } from './runtime.js';
2
3
  import { syncNoteTokenIndex } from './tokenIndex.js';
3
4
  export async function runSingleSyncPass(db, timeoutMs) {
4
- const idle = await waitForIdleDataFlow(db, Math.min(timeoutMs, 60_000));
5
- const tokenIndex = await syncNoteTokenIndex(db);
5
+ const deadline = Date.now() + Math.max(1, timeoutMs);
6
+ const remainingMs = () => Math.max(0, deadline - Date.now());
7
+ const downloadIdle = await waitForDownloadIdle(db, Math.max(1, Math.min(remainingMs(), 60_000)));
8
+ const indexTimeoutMs = remainingMs();
9
+ const timeout = indexTimeoutMs > 0 ? createTimeoutSignal(indexTimeoutMs) : null;
10
+ const tokenIndex = await syncNoteTokenIndex(db, {
11
+ signal: timeout?.signal ?? AbortSignal.abort(),
12
+ }).finally(() => timeout?.cancel());
13
+ const status = getStatusSnapshot(db);
14
+ const uploadIdle = !status.dataFlowStatus.uploading;
6
15
  return {
7
- idle,
16
+ idle: downloadIdle && uploadIdle,
17
+ downloadIdle,
18
+ uploadIdle,
8
19
  tokenIndex,
9
20
  };
10
21
  }
@@ -1,5 +1,6 @@
1
1
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
2
  export type NoteTokenIndexResult = {
3
+ complete: boolean;
3
4
  scanned: number;
4
5
  reindexed: number;
5
6
  skipped: number;
@@ -23,6 +24,7 @@ export declare function syncNoteTokenIndex(db: AbstractPowerSyncDatabase, option
23
24
  batchSize?: number;
24
25
  nowIso?: () => string;
25
26
  tokenize?: (text: string, limit?: number) => string[];
27
+ signal?: AbortSignal;
26
28
  }): Promise<NoteTokenIndexResult>;
27
29
  export declare function inspectNoteTokenIndex(db: AbstractPowerSyncDatabase): Promise<NoteTokenIndexInspection>;
28
30
  export declare function rebuildNoteTokenIndex(db: AbstractPowerSyncDatabase, options?: Parameters<typeof syncNoteTokenIndex>[1]): Promise<NoteTokenIndexResult>;
@@ -9,22 +9,27 @@ export async function syncNoteTokenIndex(db, options) {
9
9
  const batchSize = Math.max(1, Math.trunc(options?.batchSize ?? DEFAULT_BATCH_SIZE));
10
10
  const nowIso = options?.nowIso ?? nowIsoUtc;
11
11
  const tokenize = options?.tokenize ?? tokenizeWithJieba;
12
+ const signal = options?.signal;
13
+ if (signal?.aborted) {
14
+ return createIndexResult('', '', false);
15
+ }
12
16
  await ensureFtsTable(db);
17
+ if (signal?.aborted) {
18
+ return createIndexResult('', '', false);
19
+ }
13
20
  const state = await ensureIndexState(db, nowIso);
14
21
  let cursorUpdatedAt = state.cursor_updated_at ?? '';
15
22
  let cursorId = state.cursor_id ?? '';
16
- const result = {
17
- scanned: 0,
18
- reindexed: 0,
19
- skipped: 0,
20
- removed: 0,
21
- ftsRowsUpserted: 0,
22
- cursor: {
23
- updatedAt: cursorUpdatedAt,
24
- id: cursorId,
25
- },
26
- };
23
+ const result = createIndexResult(cursorUpdatedAt, cursorId, true);
24
+ if (signal?.aborted) {
25
+ result.complete = false;
26
+ return result;
27
+ }
27
28
  while (true) {
29
+ if (signal?.aborted) {
30
+ result.complete = false;
31
+ return result;
32
+ }
28
33
  const rows = await db.getAll(`
29
34
  SELECT
30
35
  id,
@@ -50,23 +55,40 @@ export async function syncNoteTokenIndex(db, options) {
50
55
  break;
51
56
  }
52
57
  for (const row of rows) {
58
+ if (signal?.aborted) {
59
+ result.complete = false;
60
+ await updateIndexCursor(db, cursorUpdatedAt, cursorId, nowIso);
61
+ result.cursor = { updatedAt: cursorUpdatedAt, id: cursorId };
62
+ return result;
63
+ }
53
64
  const sortKey = await indexNoteRow(db, row, result, nowIso, tokenize);
54
65
  cursorUpdatedAt = sortKey;
55
66
  cursorId = row.id;
56
67
  }
57
- await db.execute(`
58
- UPDATE note_local_index_state
59
- SET cursor_updated_at = ?, cursor_id = ?, updated_at = ?
60
- WHERE id = ?
61
- `, [cursorUpdatedAt, cursorId, nowIso(), INDEX_STATE_ID]);
68
+ await updateIndexCursor(db, cursorUpdatedAt, cursorId, nowIso);
69
+ result.cursor = { updatedAt: cursorUpdatedAt, id: cursorId };
62
70
  }
63
- await reconcileSkippedRows(db, result, batchSize, nowIso, tokenize);
71
+ result.complete = await reconcileSkippedRows(db, result, batchSize, nowIso, tokenize, signal);
64
72
  result.cursor = {
65
73
  updatedAt: cursorUpdatedAt,
66
74
  id: cursorId,
67
75
  };
68
76
  return result;
69
77
  }
78
+ function createIndexResult(cursorUpdatedAt, cursorId, complete) {
79
+ return {
80
+ complete,
81
+ scanned: 0,
82
+ reindexed: 0,
83
+ skipped: 0,
84
+ removed: 0,
85
+ ftsRowsUpserted: 0,
86
+ cursor: {
87
+ updatedAt: cursorUpdatedAt,
88
+ id: cursorId,
89
+ },
90
+ };
91
+ }
70
92
  export async function inspectNoteTokenIndex(db) {
71
93
  const notes = await countRows(db, `
72
94
  SELECT COUNT(*) AS count
@@ -154,8 +176,11 @@ async function indexNoteRow(db, row, result, nowIso, tokenize) {
154
176
  }
155
177
  return sortKey;
156
178
  }
157
- async function reconcileSkippedRows(db, result, batchSize, nowIso, tokenize) {
179
+ async function reconcileSkippedRows(db, result, batchSize, nowIso, tokenize, signal) {
158
180
  while (true) {
181
+ if (signal?.aborted) {
182
+ return false;
183
+ }
159
184
  const rows = await db.getAll(`
160
185
  SELECT
161
186
  n.id,
@@ -180,10 +205,16 @@ async function reconcileSkippedRows(db, result, batchSize, nowIso, tokenize) {
180
205
  break;
181
206
  }
182
207
  for (const row of rows) {
208
+ if (signal?.aborted) {
209
+ return false;
210
+ }
183
211
  await indexNoteRow(db, row, result, nowIso, tokenize);
184
212
  }
185
213
  }
186
214
  while (true) {
215
+ if (signal?.aborted) {
216
+ return false;
217
+ }
187
218
  const rows = await db.getAll(`
188
219
  SELECT m.id
189
220
  FROM note_local_index_meta m
@@ -195,11 +226,22 @@ async function reconcileSkippedRows(db, result, batchSize, nowIso, tokenize) {
195
226
  break;
196
227
  }
197
228
  for (const row of rows) {
229
+ if (signal?.aborted) {
230
+ return false;
231
+ }
198
232
  await db.execute(`DELETE FROM ${NOTE_LOCAL_FTS_TABLE} WHERE note_id = ?`, [row.id]);
199
233
  await db.execute('DELETE FROM note_local_index_meta WHERE id = ?', [row.id]);
200
234
  result.removed += 1;
201
235
  }
202
236
  }
237
+ return true;
238
+ }
239
+ async function updateIndexCursor(db, cursorUpdatedAt, cursorId, nowIso) {
240
+ await db.execute(`
241
+ UPDATE note_local_index_state
242
+ SET cursor_updated_at = ?, cursor_id = ?, updated_at = ?
243
+ WHERE id = ?
244
+ `, [cursorUpdatedAt, cursorId, nowIso(), INDEX_STATE_ID]);
203
245
  }
204
246
  async function ensureFtsTable(db) {
205
247
  try {
@@ -147,7 +147,7 @@ function defaultSuggestionForCode(code) {
147
147
  return 'Run `dino auth status --format json`; if auth is missing, set DINOX_TOKEN or pipe a token to `dino auth login --token-stdin` in your own terminal.';
148
148
  case 'SYNC_REQUIRED':
149
149
  case 'SYNC_STALE':
150
- return 'Run `dino sync --strict --sync-timeout 600000 --format json`, then retry the command.';
150
+ return 'Run `dino sync --strict --sync-timeout 20000 --format json`, then retry the command.';
151
151
  case 'UPLOAD_PENDING':
152
152
  return 'Run `dino doctor --fix --format json` to wait for pending uploads and inspect sync health.';
153
153
  case 'DAEMON_STALE':
@@ -163,7 +163,7 @@ function defaultSuggestionForCode(code) {
163
163
  return 'Run `dino auth status --format json`; if auth is missing, set DINOX_TOKEN or pipe a token to `dino auth login --token-stdin` in your own terminal.';
164
164
  }
165
165
  if (normalized.startsWith('SYNC_')) {
166
- return 'Run `dino sync --strict --sync-timeout 600000 --format json`, then retry the command.';
166
+ return 'Run `dino sync --strict --sync-timeout 20000 --format json`, then retry the command.';
167
167
  }
168
168
  if (normalized.startsWith('DAEMON_')) {
169
169
  return 'Run `dino doctor --fix --format json` to restart a stale daemon and inspect local health.';
@@ -196,7 +196,7 @@ function defaultSuggestedActionForCode(code, message) {
196
196
  }
197
197
  if (normalized === 'SYNC_REQUIRED' || normalized === 'SYNC_STALE' || normalized.startsWith('SYNC_')) {
198
198
  return {
199
- command: 'dino sync --strict --sync-timeout 600000 --format json',
199
+ command: 'dino sync --strict --sync-timeout 20000 --format json',
200
200
  reason: 'Refresh the local PowerSync cache and prove freshness before retrying the read.',
201
201
  };
202
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {
@@ -11,6 +11,8 @@
11
11
  "publish:npm:dry-run": "sh ./publish-npm.sh --dry-run",
12
12
  "skills:check": "tsx scripts/check-skills.ts",
13
13
  "skills:sync:standalone": "tsx scripts/sync-standalone-skills-repo.ts",
14
+ "skills:sync:standalone:check": "tsx scripts/sync-standalone-skills-repo.ts --check",
15
+ "skills:sync:standalone:dry-run": "tsx scripts/sync-standalone-skills-repo.ts --dry-run",
14
16
  "skills:sync": "tsx scripts/sync-skills-reference.ts",
15
17
  "start": "node dist/dinox.js",
16
18
  "test": "node scripts/run-tests.mjs",