@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 +4 -1
- package/dist/commands/runtime.js +17 -6
- package/dist/commands/sync.d.ts +23 -1
- package/dist/commands/sync.js +99 -58
- package/dist/daemon/lifecycle.d.ts +3 -1
- package/dist/daemon/lifecycle.js +6 -3
- package/dist/powersync/exclusiveRuntime.d.ts +1 -0
- package/dist/powersync/exclusiveRuntime.js +10 -4
- package/dist/powersync/runtime.d.ts +1 -0
- package/dist/powersync/runtime.js +24 -2
- package/dist/powersync/syncGate.d.ts +2 -1
- package/dist/powersync/syncGate.js +4 -1
- package/dist/powersync/syncPass.d.ts +2 -0
- package/dist/powersync/syncPass.js +15 -4
- package/dist/powersync/tokenIndex.d.ts +2 -0
- package/dist/powersync/tokenIndex.js +60 -18
- package/dist/utils/errors.js +3 -3
- package/package.json +3 -1
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
|
|
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
|
|
package/dist/commands/runtime.js
CHANGED
|
@@ -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,
|
|
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
|
|
98
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
});
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import
|
|
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 {};
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {};
|
package/dist/daemon/lifecycle.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
5
|
-
const
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
58
|
-
|
|
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 {
|
package/dist/utils/errors.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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",
|