tickdb-ts 0.0.0-stage → 0.3.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.
@@ -0,0 +1,100 @@
1
+ import type { FileEntry } from './types.js';
2
+ /** local_manifest 全字段行(stale per-file ts 边界删除用)。 */
3
+ export interface LocalManifestRow {
4
+ path: string;
5
+ sha256: string;
6
+ cycle: string;
7
+ year: number | null;
8
+ ts_min: string | null;
9
+ ts_max: string | null;
10
+ }
11
+ /** sync_meta key:kline 存储模式('duckdb-upsert' | 'parquet-direct')。 */
12
+ export declare const STORE_MODE_KEY = "store_mode";
13
+ export declare const SCHEMA_STATEMENTS: string[];
14
+ /** open() heal 目标:catalog 中可能处于 read_parquet VIEW 态的名字
15
+ * (kline parquet-direct + 5 个 extra 聚合)。每个名字必须在
16
+ * SCHEMA_STATEMENTS 有对应 CREATE TABLE IF NOT EXISTS 条目——heal 只拆
17
+ * 不建,重建全靠随后的 SCHEMA_STATEMENTS 重放(静态测试防重命名哑弹)。 */
18
+ export declare const SURFACE_HEAL_VIEW_NAMES: readonly string[];
19
+ export declare class DuckdbStore {
20
+ private instance;
21
+ private conn;
22
+ /** v2 parquet-direct:kline view 挂载根(<dataDir>/.cache/parquet)。 */
23
+ private parquetRoot;
24
+ /** surface 维护互斥(I1):单连接无锁,Node 单线程但 await 点交错——
25
+ * 并发 query()/sync 的「catalog 读型 + DDL」序列交错会把 DROP TABLE
26
+ * 落在对方刚挂的 VIEW 上(Catalog Error)。promise-chain 把整段
27
+ * 序列串行化;错误吞进链尾不阻塞后续。 */
28
+ private surfaceLock;
29
+ private constructor();
30
+ static open(path: string): Promise<DuckdbStore>;
31
+ /** 串行化辅助(I1):fn 排进 surfaceLock 链尾执行;本类所有
32
+ * 「catalog 读型 + DDL」序列的公共入口都必须经它(core 方法无锁,
33
+ * 供锁内复用,防嵌套自死锁)。 */
34
+ private runExclusive;
35
+ /** open() 残落 view heal(重启防 brick,C1):上次进程退出时 catalog
36
+ * 可能残留指向已删 parquet 的 read_parquet VIEW(stale 删除 extra/
37
+ * kline 文件是 sync 常规路径,而 ensureExtraSurfaces 的自愈只在
38
+ * query() 里跑——sync-only 用法的暴露窗口无界)。本 DuckDB 构建的
39
+ * CREATE VIEW 是 eager binder:SCHEMA_STATEMENTS 里 qfq/hfq DDL 会
40
+ * 即解析被引用的 kline / adjustment_factors 的 schema,残落 view 读
41
+ * 已删文件 footer → IO Error → open() 永久失败(客户端 brick,只能
42
+ * 手工删库 → 丢 local_manifest 全量重下)。kline 半边(v1.3 direct
43
+ * 起潜伏)与 extra 半边(v1.4)一并 heal:
44
+ * ① qfq/hfq 先拆(kline 的依赖者,SCHEMA 重放马上 OR REPLACE 重建);
45
+ * ② 处于 VIEW 态的名字一律 DROP 回空表位——重建交给 SCHEMA_STATEMENTS
46
+ * 重放(IF NOT EXISTS,单一来源)。upsert 模式的 TABLE 数据不受影响;
47
+ * direct/extra view 由 query()/syncOnce 的 surface 维护重挂。 */
48
+ private healResidueViews;
49
+ private c;
50
+ close(): Promise<void>;
51
+ run(sql: string, params?: unknown[]): Promise<void>;
52
+ query(sql: string): Promise<Record<string, unknown>[]>;
53
+ /** v2 parquet-direct 查询前置:glob 重展开(view 注册时展开 glob,新文件才
54
+ * 可见——与服务端 refresh_views 同因)。upsert 模式零开销直通。
55
+ * sync_meta 读取走原始连接(getSyncMeta → query → 这里,避免自递归)。 */
56
+ private maybeRefreshKlineSurfaceCore;
57
+ setParquetRoot(root: string): void;
58
+ /** v2 parquet-direct:kline 以 view 挂载 parquet 目录(hive_partitioning 从
59
+ * cycle={c} 目录名注入 cycle 列——parquet 本身无 cycle 列;与服务端
60
+ * query_engine 同款)。CREATE OR REPLACE 保证幂等。空目录(sync 前置调用)
61
+ * 建同 schema 空 view,read_parquet 空 glob 会 IO Error。 */
62
+ refreshKlineView(cacheRoot: string): Promise<void>;
63
+ private refreshKlineViewCore;
64
+ /** 把 kline 表面(surface)对齐到当前模式:direct → read_parquet VIEW
65
+ * (v1 遗留 TABLE 先 DROP);upsert → TABLE(v2 遗留 VIEW 先 DROP 重建,
66
+ * 服务端回滚场景)。依赖视图 qfq/hfq 一并重建。幂等。 */
67
+ ensureKlineSurface(mode: string, cacheRoot: string): Promise<void>;
68
+ private ensureKlineSurfaceCore;
69
+ /** sync_meta 读取走原始连接(不经 query() 的 surface 刷新)。 */
70
+ getSyncMeta(key: string): Promise<string | null>;
71
+ /** extra 数据集 view 挂载:**恒 direct**(服务端聚合已全局去重,天然
72
+ * disjoint——不跟随 kline 的 store_mode;upsert 模式客户端的 extra 也
73
+ * 用 view,防聚合 parquet 被灌进 kline 表)。文件存在 → DROP schema 空
74
+ * 表 + read_parquet VIEW(时间列 CAST 与服务端 view 一致);缺失 →
75
+ * 保留空表(新装客户端查询 0 行,体验不变)。
76
+ *
77
+ * 残落防护(catalog 判型——DuckDB 的 `DROP TABLE IF EXISTS` 对同名
78
+ * VIEW 照样报错,IF EXISTS 只兜「名字不存在」):BASE TABLE 且文件在
79
+ * → DROP TABLE + 挂 VIEW;VIEW 且文件在 → OR REPLACE 幂等重挂;VIEW
80
+ * 且文件缺失(stale 删除/清缓存后残落)→ DROP VIEW 回落空表
81
+ * (SCHEMA_STATEMENTS 全 IF NOT EXISTS 重建);BASE TABLE 且文件缺
82
+ * 失 → 不动。adjustment_factors 被 kline_qfq/hfq ASOF JOIN 依赖——
83
+ * duckdb view 晚绑定允许 DROP 后重解析(回归测试钉住该行为)。 */
84
+ ensureExtraSurfaces(): Promise<void>;
85
+ private ensureExtraSurfacesCore;
86
+ setSyncMeta(key: string, value: string): Promise<void>;
87
+ clearLocalManifest(): Promise<void>;
88
+ countParquetRows(parquetPath: string): Promise<number>;
89
+ upsertKlineFromFile(parquetPath: string, cycle: string): Promise<void>;
90
+ deleteKlineYear(cycle: string, year: number): Promise<void>;
91
+ /** 按文件 ts 边界删除(含端点,口径同 deleteKlineYear)。月切片 ts 互不重叠,不会误伤相邻月。 */
92
+ deleteKlineRange(cycle: string, tsMin: string, tsMax: string): Promise<void>;
93
+ listLocalManifest(): Promise<Map<string, string>>;
94
+ /** 全字段 local_manifest(stale per-file ts 边界删除用;NULL 边界 → null,调用方回退整年)。 */
95
+ listLocalManifestFull(): Promise<Map<string, LocalManifestRow>>;
96
+ upsertLocalManifest(f: FileEntry): Promise<void>;
97
+ deleteLocalManifest(paths: string[]): Promise<void>;
98
+ getEtag(): Promise<string | null>;
99
+ setEtag(etag: string): Promise<void>;
100
+ }
package/dist/store.js ADDED
@@ -0,0 +1,401 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DuckDBInstance } from '@duckdb/node-api';
4
+ import { yearBounds } from './util.js';
5
+ /** sync_meta key:kline 存储模式('duckdb-upsert' | 'parquet-direct')。 */
6
+ export const STORE_MODE_KEY = 'store_mode';
7
+ /** 与 Rust 客户端 KLINE_SCHEMA 逐条对齐(duckdb_store.rs)。 */
8
+ const KLINE_TABLE_DDL = `CREATE TABLE IF NOT EXISTS kline (
9
+ ts TIMESTAMP, symbol VARCHAR, cycle VARCHAR,
10
+ open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE,
11
+ volume BIGINT, amount DOUBLE,
12
+ PRIMARY KEY (symbol, cycle, ts))`;
13
+ const KLINE_QFQ_VIEW_DDL = `CREATE OR REPLACE VIEW kline_qfq AS
14
+ SELECT k.ts, k.symbol, k.cycle,
15
+ k.open * COALESCE(f.factor, 1.0) AS open, k.high * COALESCE(f.factor, 1.0) AS high,
16
+ k.low * COALESCE(f.factor, 1.0) AS low, k.close * COALESCE(f.factor, 1.0) AS close,
17
+ k.volume, k.amount
18
+ FROM kline k
19
+ ASOF LEFT JOIN adjustment_factors f
20
+ ON f.symbol = k.symbol AND f.adjust_type = 'qfq' AND k.ts >= f.effective_from`;
21
+ const KLINE_HFQ_VIEW_DDL = `CREATE OR REPLACE VIEW kline_hfq AS
22
+ SELECT k.ts, k.symbol, k.cycle,
23
+ k.open * COALESCE(f.factor, 1.0) AS open, k.high * COALESCE(f.factor, 1.0) AS high,
24
+ k.low * COALESCE(f.factor, 1.0) AS low, k.close * COALESCE(f.factor, 1.0) AS close,
25
+ k.volume, k.amount
26
+ FROM kline k
27
+ ASOF LEFT JOIN adjustment_factors f
28
+ ON f.symbol = k.symbol AND f.adjust_type = 'hfq' AND k.ts >= f.effective_from`;
29
+ export const SCHEMA_STATEMENTS = [
30
+ KLINE_TABLE_DDL,
31
+ `CREATE TABLE IF NOT EXISTS subscriptions (symbol VARCHAR, cycle VARCHAR, added_at TIMESTAMP, PRIMARY KEY (symbol, cycle))`,
32
+ `CREATE TABLE IF NOT EXISTS sync_state (symbol VARCHAR, cycle VARCHAR, last_ts TIMESTAMP, last_sync_at TIMESTAMP, last_rows BIGINT, PRIMARY KEY (symbol, cycle))`,
33
+ `CREATE TABLE IF NOT EXISTS sync_state_extra (data_type VARCHAR, last_sync_at TIMESTAMP, last_rows BIGINT, notes VARCHAR, PRIMARY KEY (data_type))`,
34
+ `CREATE TABLE IF NOT EXISTS securities (symbol VARCHAR PRIMARY KEY, code VARCHAR, name VARCHAR, market VARCHAR, fetched_at TIMESTAMP)`,
35
+ `CREATE TABLE IF NOT EXISTS quotes_snapshot (
36
+ ts TIMESTAMP, symbol VARCHAR, price DOUBLE, pre_close DOUBLE, open DOUBLE, high DOUBLE, low DOUBLE,
37
+ volume DOUBLE, amount DOUBLE,
38
+ bid1 DOUBLE, bid2 DOUBLE, bid3 DOUBLE, bid4 DOUBLE, bid5 DOUBLE,
39
+ ask1 DOUBLE, ask2 DOUBLE, ask3 DOUBLE, ask4 DOUBLE, ask5 DOUBLE,
40
+ bid_vol1 DOUBLE, bid_vol2 DOUBLE, bid_vol3 DOUBLE, bid_vol4 DOUBLE, bid_vol5 DOUBLE,
41
+ ask_vol1 DOUBLE, ask_vol2 DOUBLE, ask_vol3 DOUBLE, ask_vol4 DOUBLE, ask_vol5 DOUBLE,
42
+ PRIMARY KEY (symbol, ts))`,
43
+ `CREATE TABLE IF NOT EXISTS dividend (
44
+ symbol VARCHAR, date DATE, category SMALLINT, songzhuan DOUBLE, peigu DOUBLE, peigujia DOUBLE,
45
+ fenhong DOUBLE, liutong DOUBLE, zongguben DOUBLE,
46
+ PRIMARY KEY (symbol, date, category))`,
47
+ `CREATE TABLE IF NOT EXISTS sector_members (
48
+ block_type VARCHAR, block_name VARCHAR, symbol VARCHAR, name VARCHAR, market VARCHAR, asof_date DATE,
49
+ PRIMARY KEY (block_type, block_name, symbol, asof_date))`,
50
+ `CREATE TABLE IF NOT EXISTS financial_basic (
51
+ symbol VARCHAR, fetched_at TIMESTAMP, eps DOUBLE, bvps DOUBLE, cfps DOUBLE,
52
+ capital_reserve DOUBLE, retained_profit DOUBLE, operating_income DOUBLE, net_profit DOUBLE,
53
+ gross_profit DOUBLE, total_assets DOUBLE, net_assets DOUBLE, debt_ratio DOUBLE,
54
+ roe DOUBLE, gross_margin DOUBLE, net_margin DOUBLE,
55
+ PRIMARY KEY (symbol, fetched_at))`,
56
+ `CREATE TABLE IF NOT EXISTS adjustment_factors (
57
+ symbol VARCHAR, effective_from TIMESTAMP, factor DOUBLE, source_date DATE,
58
+ source_category SMALLINT, adjust_type VARCHAR,
59
+ PRIMARY KEY (symbol, adjust_type, effective_from))`,
60
+ KLINE_QFQ_VIEW_DDL,
61
+ KLINE_HFQ_VIEW_DDL,
62
+ `CREATE TABLE IF NOT EXISTS local_manifest (
63
+ path VARCHAR PRIMARY KEY, sha256 VARCHAR, size BIGINT, row_count BIGINT,
64
+ cycle VARCHAR, year INTEGER, ts_min TIMESTAMP, ts_max TIMESTAMP, synced_at TIMESTAMP)`,
65
+ `CREATE TABLE IF NOT EXISTS sync_meta (key VARCHAR PRIMARY KEY, value VARCHAR)`,
66
+ ];
67
+ /** extra 数据集 view 规格:(表名, 时间列)。与服务端 query_engine 的 extra
68
+ * view 同一张表——时间列 CAST 成 plain TIMESTAMP(parquet TIMESTAMPTZ
69
+ * 剥 tz 走 session 时区);dividend/sector_members 时间列是 DATE(无需
70
+ * CAST);quotes_snapshot 不同步,缺席。 */
71
+ const EXTRA_DATASET_VIEW_SPECS = [
72
+ ['securities', 'fetched_at'],
73
+ ['dividend', null],
74
+ ['financial_basic', 'fetched_at'],
75
+ ['sector_members', null],
76
+ ['adjustment_factors', 'effective_from'],
77
+ ];
78
+ /** open() heal 目标:catalog 中可能处于 read_parquet VIEW 态的名字
79
+ * (kline parquet-direct + 5 个 extra 聚合)。每个名字必须在
80
+ * SCHEMA_STATEMENTS 有对应 CREATE TABLE IF NOT EXISTS 条目——heal 只拆
81
+ * 不建,重建全靠随后的 SCHEMA_STATEMENTS 重放(静态测试防重命名哑弹)。 */
82
+ export const SURFACE_HEAL_VIEW_NAMES = ['kline', ...EXTRA_DATASET_VIEW_SPECS.map(([n]) => n)];
83
+ export class DuckdbStore {
84
+ instance = null;
85
+ conn = null;
86
+ /** v2 parquet-direct:kline view 挂载根(<dataDir>/.cache/parquet)。 */
87
+ parquetRoot = null;
88
+ /** surface 维护互斥(I1):单连接无锁,Node 单线程但 await 点交错——
89
+ * 并发 query()/sync 的「catalog 读型 + DDL」序列交错会把 DROP TABLE
90
+ * 落在对方刚挂的 VIEW 上(Catalog Error)。promise-chain 把整段
91
+ * 序列串行化;错误吞进链尾不阻塞后续。 */
92
+ surfaceLock = Promise.resolve();
93
+ constructor() { }
94
+ static async open(path) {
95
+ const s = new DuckdbStore();
96
+ try {
97
+ s.instance = await DuckDBInstance.create(path);
98
+ s.conn = await s.instance.connect();
99
+ }
100
+ catch (e) {
101
+ const msg = e instanceof Error ? e.message : String(e);
102
+ if (msg.includes('Conflicting lock')) {
103
+ throw new Error(`tickdb 数据库被另一进程占用(如 python/streamlit 或另一个 Node 进程):${path}。停止对方进程后重试。`);
104
+ }
105
+ throw e;
106
+ }
107
+ // 时区 pin:DuckDB TIMESTAMPTZ->TIMESTAMP 按 session tz 剥离,必须 UTC(与 Rust 客户端一致,否则 bar 翻倍)
108
+ await s.conn.run(`SET timezone='UTC'`);
109
+ await s.healResidueViews();
110
+ for (const ddl of SCHEMA_STATEMENTS)
111
+ await s.conn.run(ddl);
112
+ return s;
113
+ }
114
+ /** 串行化辅助(I1):fn 排进 surfaceLock 链尾执行;本类所有
115
+ * 「catalog 读型 + DDL」序列的公共入口都必须经它(core 方法无锁,
116
+ * 供锁内复用,防嵌套自死锁)。 */
117
+ runExclusive(fn) {
118
+ const run = this.surfaceLock.then(fn);
119
+ this.surfaceLock = run.then(() => undefined, () => undefined);
120
+ return run;
121
+ }
122
+ /** open() 残落 view heal(重启防 brick,C1):上次进程退出时 catalog
123
+ * 可能残留指向已删 parquet 的 read_parquet VIEW(stale 删除 extra/
124
+ * kline 文件是 sync 常规路径,而 ensureExtraSurfaces 的自愈只在
125
+ * query() 里跑——sync-only 用法的暴露窗口无界)。本 DuckDB 构建的
126
+ * CREATE VIEW 是 eager binder:SCHEMA_STATEMENTS 里 qfq/hfq DDL 会
127
+ * 即解析被引用的 kline / adjustment_factors 的 schema,残落 view 读
128
+ * 已删文件 footer → IO Error → open() 永久失败(客户端 brick,只能
129
+ * 手工删库 → 丢 local_manifest 全量重下)。kline 半边(v1.3 direct
130
+ * 起潜伏)与 extra 半边(v1.4)一并 heal:
131
+ * ① qfq/hfq 先拆(kline 的依赖者,SCHEMA 重放马上 OR REPLACE 重建);
132
+ * ② 处于 VIEW 态的名字一律 DROP 回空表位——重建交给 SCHEMA_STATEMENTS
133
+ * 重放(IF NOT EXISTS,单一来源)。upsert 模式的 TABLE 数据不受影响;
134
+ * direct/extra view 由 query()/syncOnce 的 surface 维护重挂。 */
135
+ async healResidueViews() {
136
+ await this.c().run('DROP VIEW IF EXISTS kline_qfq; DROP VIEW IF EXISTS kline_hfq');
137
+ for (const name of SURFACE_HEAL_VIEW_NAMES) {
138
+ const r = await this.c().run(`SELECT table_type FROM information_schema.tables
139
+ WHERE table_schema='main' AND table_name='${name}'`);
140
+ const ty = (await r.getRows())[0]?.[0];
141
+ if (ty === 'VIEW')
142
+ await this.c().run(`DROP VIEW IF EXISTS ${name}`);
143
+ }
144
+ }
145
+ c() {
146
+ if (!this.conn)
147
+ throw new Error('store closed');
148
+ return this.conn;
149
+ }
150
+ async close() {
151
+ try {
152
+ this.conn?.closeSync();
153
+ }
154
+ catch { /* already closed */ }
155
+ this.conn = null;
156
+ try {
157
+ this.instance?.closeSync();
158
+ }
159
+ catch { /* already closed */ }
160
+ this.instance = null;
161
+ }
162
+ async run(sql, params) {
163
+ await this.c().run(sql, params);
164
+ }
165
+ async query(sql) {
166
+ // surface 维护整段串行化(I1)且 extra 修复先于 kline refresh(qfq/hfq
167
+ // view 的 CREATE 在本 DuckDB 构建下即解析被引用的 adjustment_factors
168
+ // schema——若它还是残落 read_parquet VIEW 会先炸 footer 读取)。两步
169
+ // 必须在同一临界区:并发交错既会 DROP TABLE 落 VIEW(Catalog Error),
170
+ // 也会把 extra heal 的名字短暂消失窗口暴露给对方刚建的 qfq/hfq。
171
+ await this.runExclusive(async () => {
172
+ await this.ensureExtraSurfacesCore();
173
+ await this.maybeRefreshKlineSurfaceCore();
174
+ });
175
+ const r = await this.c().run(sql);
176
+ const cols = r.columnNames();
177
+ const rows = await r.getRows();
178
+ return rows.map((row) => Object.fromEntries(cols.map((c, i) => [c, mapValue(row[i])])));
179
+ }
180
+ /** v2 parquet-direct 查询前置:glob 重展开(view 注册时展开 glob,新文件才
181
+ * 可见——与服务端 refresh_views 同因)。upsert 模式零开销直通。
182
+ * sync_meta 读取走原始连接(getSyncMeta → query → 这里,避免自递归)。 */
183
+ async maybeRefreshKlineSurfaceCore() {
184
+ if (!this.parquetRoot)
185
+ return;
186
+ const r = await this.c().run(`SELECT value FROM sync_meta WHERE key = '${escapeSql(STORE_MODE_KEY)}'`);
187
+ const rows = await r.getRows();
188
+ if (rows[0]?.[0] === 'parquet-direct') {
189
+ await this.refreshKlineViewCore(this.parquetRoot);
190
+ }
191
+ }
192
+ setParquetRoot(root) {
193
+ this.parquetRoot = root;
194
+ }
195
+ /** v2 parquet-direct:kline 以 view 挂载 parquet 目录(hive_partitioning 从
196
+ * cycle={c} 目录名注入 cycle 列——parquet 本身无 cycle 列;与服务端
197
+ * query_engine 同款)。CREATE OR REPLACE 保证幂等。空目录(sync 前置调用)
198
+ * 建同 schema 空 view,read_parquet 空 glob 会 IO Error。 */
199
+ async refreshKlineView(cacheRoot) {
200
+ await this.runExclusive(() => this.refreshKlineViewCore(cacheRoot));
201
+ }
202
+ async refreshKlineViewCore(cacheRoot) {
203
+ // kline 可能处于 TABLE 态(open() heal 拆回空表 / upsert 遗留)——
204
+ // CREATE OR REPLACE VIEW 不跨类型替换,先拆表(依赖视图一并拆,
205
+ // 随后重建)。VIEW 态则 OR REPLACE 幂等。
206
+ const t = await this.c().run(`SELECT table_type FROM information_schema.tables
207
+ WHERE table_schema='main' AND table_name='kline'`);
208
+ const kty = (await t.getRows())[0]?.[0];
209
+ if (kty === 'BASE TABLE') {
210
+ await this.c().run('DROP VIEW IF EXISTS kline_qfq; DROP VIEW IF EXISTS kline_hfq; DROP TABLE kline');
211
+ }
212
+ const glob = `${cacheRoot.replaceAll("'", '')}/kline-compacted/cycle=*/*.parquet`;
213
+ const r = await this.c().run(`SELECT COUNT(*) AS n FROM glob('${glob}')`);
214
+ const n = Number((await r.getRows())[0][0]);
215
+ if (n > 0) {
216
+ await this.c().run(`CREATE OR REPLACE VIEW kline AS
217
+ SELECT * REPLACE (CAST(ts AS TIMESTAMP) AS ts)
218
+ FROM read_parquet('${glob}', hive_partitioning=1)`);
219
+ }
220
+ else {
221
+ // 列序与 read_parquet+hive 一致(分区列 cycle 追加在末尾)
222
+ await this.c().run(`CREATE OR REPLACE VIEW kline AS
223
+ SELECT CAST(NULL AS TIMESTAMP) AS ts, CAST(NULL AS VARCHAR) AS symbol,
224
+ CAST(NULL AS DOUBLE) AS open, CAST(NULL AS DOUBLE) AS high,
225
+ CAST(NULL AS DOUBLE) AS low, CAST(NULL AS DOUBLE) AS close,
226
+ CAST(NULL AS BIGINT) AS volume, CAST(NULL AS DOUBLE) AS amount,
227
+ CAST(NULL AS VARCHAR) AS cycle
228
+ WHERE 1=0`);
229
+ }
230
+ await this.c().run(KLINE_QFQ_VIEW_DDL);
231
+ await this.c().run(KLINE_HFQ_VIEW_DDL);
232
+ }
233
+ /** 把 kline 表面(surface)对齐到当前模式:direct → read_parquet VIEW
234
+ * (v1 遗留 TABLE 先 DROP);upsert → TABLE(v2 遗留 VIEW 先 DROP 重建,
235
+ * 服务端回滚场景)。依赖视图 qfq/hfq 一并重建。幂等。 */
236
+ async ensureKlineSurface(mode, cacheRoot) {
237
+ await this.runExclusive(() => this.ensureKlineSurfaceCore(mode, cacheRoot));
238
+ }
239
+ async ensureKlineSurfaceCore(mode, cacheRoot) {
240
+ // 原始连接查询(绕过 query() 的 surface 刷新,避免自激)
241
+ const r = await this.c().run(`SELECT table_type FROM information_schema.tables
242
+ WHERE table_schema='main' AND table_name='kline'`);
243
+ const rows = await r.getRows();
244
+ const ty = rows[0]?.[0];
245
+ if (mode === 'parquet-direct') {
246
+ // TABLE→VIEW 拆除在 refreshKlineViewCore 内(heal 后 kline 是空表)
247
+ await this.refreshKlineViewCore(cacheRoot);
248
+ }
249
+ else if (ty === 'VIEW') {
250
+ await this.run('DROP VIEW IF EXISTS kline_qfq; DROP VIEW IF EXISTS kline_hfq; DROP VIEW kline;');
251
+ await this.run(KLINE_TABLE_DDL);
252
+ await this.run(KLINE_QFQ_VIEW_DDL);
253
+ await this.run(KLINE_HFQ_VIEW_DDL);
254
+ }
255
+ }
256
+ /** sync_meta 读取走原始连接(不经 query() 的 surface 刷新)。 */
257
+ async getSyncMeta(key) {
258
+ const r = await this.c().run(`SELECT value FROM sync_meta WHERE key = '${escapeSql(key)}'`);
259
+ const rows = await r.getRows();
260
+ return rows.length > 0 ? rows[0][0] : null;
261
+ }
262
+ /** extra 数据集 view 挂载:**恒 direct**(服务端聚合已全局去重,天然
263
+ * disjoint——不跟随 kline 的 store_mode;upsert 模式客户端的 extra 也
264
+ * 用 view,防聚合 parquet 被灌进 kline 表)。文件存在 → DROP schema 空
265
+ * 表 + read_parquet VIEW(时间列 CAST 与服务端 view 一致);缺失 →
266
+ * 保留空表(新装客户端查询 0 行,体验不变)。
267
+ *
268
+ * 残落防护(catalog 判型——DuckDB 的 `DROP TABLE IF EXISTS` 对同名
269
+ * VIEW 照样报错,IF EXISTS 只兜「名字不存在」):BASE TABLE 且文件在
270
+ * → DROP TABLE + 挂 VIEW;VIEW 且文件在 → OR REPLACE 幂等重挂;VIEW
271
+ * 且文件缺失(stale 删除/清缓存后残落)→ DROP VIEW 回落空表
272
+ * (SCHEMA_STATEMENTS 全 IF NOT EXISTS 重建);BASE TABLE 且文件缺
273
+ * 失 → 不动。adjustment_factors 被 kline_qfq/hfq ASOF JOIN 依赖——
274
+ * duckdb view 晚绑定允许 DROP 后重解析(回归测试钉住该行为)。 */
275
+ async ensureExtraSurfaces() {
276
+ await this.runExclusive(() => this.ensureExtraSurfacesCore());
277
+ }
278
+ async ensureExtraSurfacesCore() {
279
+ if (!this.parquetRoot)
280
+ return; // 调用方(index.ts/sync)负责 setParquetRoot
281
+ const fellBack = [];
282
+ for (const [name, tsCol] of EXTRA_DATASET_VIEW_SPECS) {
283
+ const path = join(this.parquetRoot, 'extra', `${name}.parquet`);
284
+ // 原始连接查询(绕过 query() 的 surface 刷新,避免自激)
285
+ const r = await this.c().run(`SELECT table_type FROM information_schema.tables
286
+ WHERE table_schema='main' AND table_name='${name}'`);
287
+ const ty = (await r.getRows())[0]?.[0];
288
+ if (existsSync(path)) {
289
+ // 挂 view 前先 DROP 同名 TABLE(CREATE OR REPLACE VIEW 不跨类型
290
+ // 替换;已是 VIEW 则 OR REPLACE 幂等重挂)。IF EXISTS 双保险:
291
+ // catalog 判型已排除类型错配,兜并发 DDL 竞态。
292
+ if (ty === 'BASE TABLE') {
293
+ await this.run(`DROP TABLE IF EXISTS ${name}`);
294
+ }
295
+ const cast = tsCol
296
+ ? `SELECT * REPLACE (CAST(${tsCol} AS TIMESTAMP) AS ${tsCol})`
297
+ : 'SELECT *';
298
+ await this.run(`CREATE OR REPLACE VIEW ${name} AS ${cast}
299
+ FROM read_parquet('${escapeSql(path)}')`);
300
+ }
301
+ else if (ty === 'VIEW') {
302
+ // 残落 view(底座文件已删):DROP 回落空表
303
+ await this.run(`DROP VIEW IF EXISTS ${name}`);
304
+ fellBack.push(name);
305
+ }
306
+ }
307
+ if (fellBack.length > 0) {
308
+ // 只重建被 DROP 的 extra 空表(SCHEMA_STATEMENTS 里现成 DDL,单一
309
+ // 来源)——不跑全量:qfq/hfq 的 CREATE VIEW 会即解析被引用的
310
+ // kline,若 kline 此刻也是残落 view(整缓存被清场景)会 IO Error。
311
+ for (const name of fellBack) {
312
+ const ddl = SCHEMA_STATEMENTS.find((s) => s.startsWith(`CREATE TABLE IF NOT EXISTS ${name} `));
313
+ if (ddl)
314
+ await this.run(ddl);
315
+ }
316
+ }
317
+ }
318
+ async setSyncMeta(key, value) {
319
+ await this.run(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)`, [key, value]);
320
+ }
321
+ async clearLocalManifest() {
322
+ await this.run('DELETE FROM local_manifest');
323
+ }
324
+ async countParquetRows(parquetPath) {
325
+ const r = await this.c().run(`SELECT COUNT(*) AS n FROM read_parquet('${escapeSql(parquetPath)}')`);
326
+ return Number((await r.getRows())[0][0]);
327
+ }
328
+ async upsertKlineFromFile(parquetPath, cycle) {
329
+ await this.run(`INSERT OR REPLACE INTO kline (ts, symbol, cycle, open, high, low, close, volume, amount)
330
+ SELECT ts, symbol, '${escapeSql(cycle)}', open, high, low, close, volume, amount
331
+ FROM read_parquet('${escapeSql(parquetPath)}')`);
332
+ }
333
+ async deleteKlineYear(cycle, year) {
334
+ const [lo, hi] = yearBounds(year);
335
+ await this.run(`DELETE FROM kline WHERE cycle = ? AND ts >= CAST(? AS TIMESTAMP) AND ts <= CAST(? AS TIMESTAMP)`, [cycle, lo, hi]);
336
+ }
337
+ /** 按文件 ts 边界删除(含端点,口径同 deleteKlineYear)。月切片 ts 互不重叠,不会误伤相邻月。 */
338
+ async deleteKlineRange(cycle, tsMin, tsMax) {
339
+ await this.run(`DELETE FROM kline WHERE cycle = ? AND ts >= CAST(? AS TIMESTAMP) AND ts <= CAST(? AS TIMESTAMP)`, [cycle, tsMin, tsMax]);
340
+ }
341
+ async listLocalManifest() {
342
+ const rows = await this.query('SELECT path, sha256 FROM local_manifest');
343
+ return new Map(rows.map((r) => [r.path, r.sha256]));
344
+ }
345
+ /** 全字段 local_manifest(stale per-file ts 边界删除用;NULL 边界 → null,调用方回退整年)。 */
346
+ async listLocalManifestFull() {
347
+ const rows = await this.query('SELECT path, sha256, cycle, year, ts_min, ts_max FROM local_manifest');
348
+ return new Map(rows.map((r) => [r.path, {
349
+ path: r.path,
350
+ sha256: r.sha256,
351
+ cycle: r.cycle,
352
+ year: r.year === null || r.year === undefined ? null : Number(r.year),
353
+ ts_min: r.ts_min === null || r.ts_min === undefined ? null : sqlTs(String(r.ts_min)),
354
+ ts_max: r.ts_max === null || r.ts_max === undefined ? null : sqlTs(String(r.ts_max)),
355
+ }]));
356
+ }
357
+ async upsertLocalManifest(f) {
358
+ await this.run(`INSERT OR REPLACE INTO local_manifest
359
+ (path, sha256, size, row_count, cycle, year, ts_min, ts_max, synced_at)
360
+ VALUES (?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), now())`, [f.path, f.sha256, f.size, f.row_count, f.cycle, f.year ?? null, f.ts_min, f.ts_max]);
361
+ }
362
+ async deleteLocalManifest(paths) {
363
+ for (let i = 0; i < paths.length; i += 500) {
364
+ const chunk = paths.slice(i, i + 500);
365
+ await this.run(`DELETE FROM local_manifest WHERE path IN (${chunk.map(() => '?').join(',')})`, chunk);
366
+ }
367
+ }
368
+ async getEtag() {
369
+ const rows = await this.query(`SELECT value FROM sync_meta WHERE key = 'manifest_etag'`);
370
+ return rows.length > 0 ? rows[0].value : null;
371
+ }
372
+ async setEtag(etag) {
373
+ await this.run(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('manifest_etag', ?)`, [etag]);
374
+ }
375
+ }
376
+ function escapeSql(s) {
377
+ return s.replaceAll("'", "''");
378
+ }
379
+ /** mapValue 产出的 ISO('2026-02-05T15:00:00.000Z')→ SQL timestamp 字面量('2026-02-05 15:00:00')。 */
380
+ function sqlTs(iso) {
381
+ return iso.replace('T', ' ').replace(/(\.\d+)?Z$/, '');
382
+ }
383
+ /** DuckDB node-api 值 -> JSON 友好值(对齐 Python cell_to_json 行为)。 */
384
+ function mapValue(v) {
385
+ if (v === null || typeof v !== 'object') {
386
+ if (typeof v === 'bigint')
387
+ return Number(v);
388
+ return v;
389
+ }
390
+ const name = v.constructor?.name;
391
+ if (name === 'DuckDBTimestampValue' || name === 'DuckDBTimestampTZValue') {
392
+ return new Date(String(v).replace(' ', 'T') + 'Z').toISOString();
393
+ }
394
+ if (name === 'DuckDBDecimalValue')
395
+ return v.toDouble();
396
+ if (name === 'DuckDBDateValue' || name === 'DuckDBTimeValue' || name === 'DuckDBIntervalValue'
397
+ || name === 'DuckDBUUIDValue' || name === 'DuckDBBigIntValue' || name === 'DuckDBHugeIntValue') {
398
+ return String(v);
399
+ }
400
+ return v;
401
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { DuckdbStore } from './store.js';
2
+ import type { TickdbHttp } from './http.js';
3
+ import type { SyncReport } from './types.js';
4
+ /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换,版本门) -> diff
5
+ * -> stale 删除(direct/extra 删文件 / upsert per-file ts 边界删行)->
6
+ * runPool 并发 fetch+apply(mirror-first 全量文件;extra/ 恒 direct)->
7
+ * 零错误才写 etag。 */
8
+ export declare function syncOnce(store: DuckdbStore, http: TickdbHttp, cacheDir: string, opts?: {
9
+ downloadConcurrency?: number;
10
+ }): Promise<SyncReport>;
package/dist/sync.js ADDED
@@ -0,0 +1,142 @@
1
+ import { join } from 'node:path';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { STORE_MODE_KEY } from './store.js';
4
+ import { computeDiff } from './diff.js';
5
+ import { isExtraFile, parseCycleYear, runPool } from './util.js';
6
+ /** v2 嵌套布局(镜像服务端路径,hive cycle 注入依赖 cycle={c} 目录层级)。 */
7
+ const cachePath = (cacheDir, p) => join(cacheDir, p);
8
+ /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换,版本门) -> diff
9
+ * -> stale 删除(direct/extra 删文件 / upsert per-file ts 边界删行)->
10
+ * runPool 并发 fetch+apply(mirror-first 全量文件;extra/ 恒 direct)->
11
+ * 零错误才写 etag。 */
12
+ export async function syncOnce(store, http, cacheDir, opts = {}) {
13
+ const report = {
14
+ notModified: false, filesTotal: 0, filesFetched: 0, filesUnchanged: 0,
15
+ rowsUpserted: 0, staleDeleted: 0, mirrorFetched: 0, mirrorFallbacks: 0, errors: [],
16
+ };
17
+ const etag = await store.getEtag();
18
+ const res = await http.fetchManifest(etag ?? undefined);
19
+ if (res.kind === 'not_modified') {
20
+ report.notModified = true;
21
+ report.filesTotal = (await store.listLocalManifest()).size;
22
+ report.filesUnchanged = report.filesTotal;
23
+ return report;
24
+ }
25
+ const { manifest, etag: newEtag } = res;
26
+ report.filesTotal = manifest.files.length;
27
+ // ① 模式判定 + 切换(v1.3 disjoint → parquet-direct:文件即行集不 upsert)。
28
+ // 版本门:只有携带权威 disjoint 字段的 body(v1.3/v1.4)才允许驱动
29
+ // store_mode 翻转/缓存清除——pre-v1.4 服务端对未知 ?version=v1.4 走默认
30
+ // 分支回 v1.1 body(disjoint 缺省),若无此门 direct 客户端会被静默翻回
31
+ // upsert:清缓存 + 全量重下 + 亿行 upsert(小时级慢路)。旧版本 body
32
+ // 沿袭本地既有 store_mode(无记录默认 duckdb-upsert)。
33
+ const prevMode = await store.getSyncMeta(STORE_MODE_KEY);
34
+ const versionGated = manifest.version === 'v1.3' || manifest.version === 'v1.4';
35
+ const mode = versionGated
36
+ ? (manifest.disjoint === true ? 'parquet-direct' : 'duckdb-upsert')
37
+ : (prevMode ?? 'duckdb-upsert');
38
+ const direct = mode === 'parquet-direct';
39
+ if (prevMode !== mode) {
40
+ // 布局全清重来:v1→v2 旧 flat 文件/表行全废;v2→v1 回退按 manifest 重拉重灌。
41
+ // 例外:本地无 store_mode(v1 客户端升级)且服务端仍 upsert —— 数据布局
42
+ // 未变,保留 local_manifest/缓存,避免一次无意义的全量重下。
43
+ const wipe = !(prevMode === null && !direct);
44
+ if (wipe) {
45
+ rmSync(cacheDir, { recursive: true, force: true });
46
+ mkdirSync(cacheDir, { recursive: true });
47
+ await store.clearLocalManifest();
48
+ }
49
+ await store.setSyncMeta(STORE_MODE_KEY, mode);
50
+ }
51
+ store.setParquetRoot(cacheDir);
52
+ // extra surface 先于 kline surface:wipe(模式切换清缓存)后 extra view
53
+ // 残落,ensureKlineSurface 里 qfq/hfq 的 CREATE 会即解析 adjustment_
54
+ // factors(read_parquet view 读已删文件 footer → IO Error),先回落
55
+ // 空表再动 kline。
56
+ await store.ensureExtraSurfaces();
57
+ await store.ensureKlineSurface(mode, cacheDir);
58
+ const diff = computeDiff(manifest.files, await store.listLocalManifest());
59
+ report.filesUnchanged = diff.unchanged;
60
+ // ② stale 先删(best-effort:单条失败记 error 不中断)。
61
+ // direct → 删本地文件(行随文件消失);upsert → 优先按该文件自己的 ts 边界删
62
+ // (月文件消失只删它那个月;边界缺失回退整年删除)。
63
+ // extra/ 前缀在任何模式下都按删文件处理(它是 view 的底座文件,行不在
64
+ // kline 表里——upsert 模式不分流会走 parseCycleYear → 报错)。
65
+ const localFull = direct ? new Map() : await store.listLocalManifestFull();
66
+ const staleOk = [];
67
+ for (const p of diff.stale) {
68
+ try {
69
+ if (direct || isExtraFile(p)) {
70
+ rmSync(cachePath(cacheDir, p), { force: true });
71
+ }
72
+ else {
73
+ const { cycle, year } = parseCycleYear(p);
74
+ const row = localFull.get(p);
75
+ if (row?.ts_min && row?.ts_max) {
76
+ await store.deleteKlineRange(cycle, row.ts_min, row.ts_max);
77
+ }
78
+ else {
79
+ await store.deleteKlineYear(cycle, year);
80
+ }
81
+ }
82
+ staleOk.push(p);
83
+ }
84
+ catch (e) {
85
+ report.errors.push(`stale ${p}: ${e instanceof Error ? e.message : String(e)}`);
86
+ }
87
+ }
88
+ if (staleOk.length > 0) {
89
+ await store.deleteLocalManifest(staleOk);
90
+ report.staleDeleted = staleOk.length;
91
+ }
92
+ // ③ 并发 fetch + apply(单文件失败不中断整轮)。mirror-first:所有文件
93
+ // (v1.2 的 frozen gate 移除——上传面已扩到全部文件,sha 校验兜底);
94
+ // 镜像失败/非 200/sha 不符 → 回落 VPS(带重试),不计 error。
95
+ // extra/ 聚合文件无视入参 direct——upsert 模式客户端也不灌表(聚合
96
+ // parquet 的 schema 不是 kline 形状,误灌会 INSERT 报错;本地由
97
+ // ensureExtraSurfaces 挂 view 直读)。
98
+ const mirrorBase = manifest.mirror_base_url?.replace(/\/$/, '');
99
+ await runPool(diff.toFetch, opts.downloadConcurrency ?? 4, async (f) => {
100
+ try {
101
+ const dest = cachePath(cacheDir, f.path);
102
+ mkdirSync(join(dest, '..'), { recursive: true });
103
+ let viaMirror = false;
104
+ if (mirrorBase) {
105
+ const mUrl = `${mirrorBase}/${f.path}`;
106
+ try {
107
+ await http.downloadMirrorToFile(mUrl, dest, { size: f.size, sha256: f.sha256 });
108
+ viaMirror = true;
109
+ report.mirrorFetched += 1;
110
+ }
111
+ catch {
112
+ report.mirrorFallbacks += 1; // 回落 VPS,镜像失败不算 error
113
+ }
114
+ }
115
+ if (!viaMirror) {
116
+ await http.downloadToFile(f.path, f.path, dest, { size: f.size, sha256: f.sha256 });
117
+ }
118
+ const fileDirect = direct || isExtraFile(f.path);
119
+ if (fileDirect) {
120
+ // v2/extra:文件即行集——不 upsert、不删文件
121
+ await store.upsertLocalManifest(f);
122
+ report.rowsUpserted += 0;
123
+ }
124
+ else {
125
+ const rows = await store.countParquetRows(dest);
126
+ await store.upsertKlineFromFile(dest, f.cycle);
127
+ await store.upsertLocalManifest(f);
128
+ rmSync(dest, { force: true });
129
+ report.rowsUpserted += rows;
130
+ }
131
+ report.filesFetched += 1;
132
+ }
133
+ catch (e) {
134
+ report.errors.push(`${f.path}: ${e instanceof Error ? e.message : String(e)}`);
135
+ }
136
+ });
137
+ // ④ 仅零错误轮写 etag(304 => 本地确实与该 manifest 一致)
138
+ if (report.errors.length === 0 && newEtag) {
139
+ await store.setEtag(newEtag);
140
+ }
141
+ return report;
142
+ }