tickdb-ts 0.2.0 → 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.
package/dist/http.js CHANGED
@@ -22,7 +22,10 @@ export class TickdbHttp {
22
22
  const headers = this.headers();
23
23
  if (ifNoneMatch)
24
24
  headers['If-None-Match'] = ifNoneMatch;
25
- const res = await fetch(`${this.serverUrl}/api/v1/manifest?version=v1.3`, {
25
+ // v1.4 = v1.3 + extra/<name>.parquet 聚合数据集条目(disjoint 门保留)。
26
+ // pre-v1.4 服务端对未知 version 走默认分支回 v1.1 body——parseManifest
27
+ // 全版本兼容;syncOnce 的版本门保证旧 body 不翻转 store_mode。
28
+ const res = await fetch(`${this.serverUrl}/api/v1/manifest?version=v1.4`, {
26
29
  headers,
27
30
  signal: AbortSignal.timeout(60_000),
28
31
  });
package/dist/manifest.js CHANGED
@@ -1,9 +1,11 @@
1
+ const SUPPORTED_VERSIONS = ['v1', 'v1.1', 'v1.2', 'v1.3', 'v1.4'];
1
2
  /** 把线上 JSON(snake_case)解析成 Manifest,字段不合法直接抛错。 */
2
3
  export function parseManifest(raw) {
3
4
  if (typeof raw !== 'object' || raw === null)
4
5
  throw new Error('manifest: not an object');
5
6
  const r = raw;
6
- if (r.version !== 'v1' && r.version !== 'v1.1' && r.version !== 'v1.2' && r.version !== 'v1.3') {
7
+ const version = SUPPORTED_VERSIONS.find((v) => v === r.version);
8
+ if (!version) {
7
9
  throw new Error(`unsupported manifest version: ${String(r.version)}`);
8
10
  }
9
11
  if (!Array.isArray(r.files))
@@ -30,7 +32,7 @@ export function parseManifest(raw) {
30
32
  };
31
33
  });
32
34
  return {
33
- version: r.version,
35
+ version,
34
36
  schema: typeof r.schema === 'string' ? r.schema : 'symbol-partitioned',
35
37
  generatedAt: typeof r.generated_at === 'string' ? r.generated_at : '',
36
38
  files,
package/dist/store.d.ts CHANGED
@@ -10,13 +10,42 @@ export interface LocalManifestRow {
10
10
  }
11
11
  /** sync_meta key:kline 存储模式('duckdb-upsert' | 'parquet-direct')。 */
12
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[];
13
19
  export declare class DuckdbStore {
14
20
  private instance;
15
21
  private conn;
16
22
  /** v2 parquet-direct:kline view 挂载根(<dataDir>/.cache/parquet)。 */
17
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;
18
29
  private constructor();
19
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;
20
49
  private c;
21
50
  close(): Promise<void>;
22
51
  run(sql: string, params?: unknown[]): Promise<void>;
@@ -24,19 +53,36 @@ export declare class DuckdbStore {
24
53
  /** v2 parquet-direct 查询前置:glob 重展开(view 注册时展开 glob,新文件才
25
54
  * 可见——与服务端 refresh_views 同因)。upsert 模式零开销直通。
26
55
  * sync_meta 读取走原始连接(getSyncMeta → query → 这里,避免自递归)。 */
27
- private maybeRefreshKlineSurface;
56
+ private maybeRefreshKlineSurfaceCore;
28
57
  setParquetRoot(root: string): void;
29
58
  /** v2 parquet-direct:kline 以 view 挂载 parquet 目录(hive_partitioning 从
30
59
  * cycle={c} 目录名注入 cycle 列——parquet 本身无 cycle 列;与服务端
31
60
  * query_engine 同款)。CREATE OR REPLACE 保证幂等。空目录(sync 前置调用)
32
61
  * 建同 schema 空 view,read_parquet 空 glob 会 IO Error。 */
33
62
  refreshKlineView(cacheRoot: string): Promise<void>;
63
+ private refreshKlineViewCore;
34
64
  /** 把 kline 表面(surface)对齐到当前模式:direct → read_parquet VIEW
35
65
  * (v1 遗留 TABLE 先 DROP);upsert → TABLE(v2 遗留 VIEW 先 DROP 重建,
36
66
  * 服务端回滚场景)。依赖视图 qfq/hfq 一并重建。幂等。 */
37
67
  ensureKlineSurface(mode: string, cacheRoot: string): Promise<void>;
68
+ private ensureKlineSurfaceCore;
38
69
  /** sync_meta 读取走原始连接(不经 query() 的 surface 刷新)。 */
39
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;
40
86
  setSyncMeta(key: string, value: string): Promise<void>;
41
87
  clearLocalManifest(): Promise<void>;
42
88
  countParquetRows(parquetPath: string): Promise<number>;
package/dist/store.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { DuckDBInstance } from '@duckdb/node-api';
2
4
  import { yearBounds } from './util.js';
3
5
  /** sync_meta key:kline 存储模式('duckdb-upsert' | 'parquet-direct')。 */
@@ -24,7 +26,7 @@ const KLINE_HFQ_VIEW_DDL = `CREATE OR REPLACE VIEW kline_hfq AS
24
26
  FROM kline k
25
27
  ASOF LEFT JOIN adjustment_factors f
26
28
  ON f.symbol = k.symbol AND f.adjust_type = 'hfq' AND k.ts >= f.effective_from`;
27
- const SCHEMA_STATEMENTS = [
29
+ export const SCHEMA_STATEMENTS = [
28
30
  KLINE_TABLE_DDL,
29
31
  `CREATE TABLE IF NOT EXISTS subscriptions (symbol VARCHAR, cycle VARCHAR, added_at TIMESTAMP, PRIMARY KEY (symbol, cycle))`,
30
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))`,
@@ -62,11 +64,32 @@ const SCHEMA_STATEMENTS = [
62
64
  cycle VARCHAR, year INTEGER, ts_min TIMESTAMP, ts_max TIMESTAMP, synced_at TIMESTAMP)`,
63
65
  `CREATE TABLE IF NOT EXISTS sync_meta (key VARCHAR PRIMARY KEY, value VARCHAR)`,
64
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)];
65
83
  export class DuckdbStore {
66
84
  instance = null;
67
85
  conn = null;
68
86
  /** v2 parquet-direct:kline view 挂载根(<dataDir>/.cache/parquet)。 */
69
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();
70
93
  constructor() { }
71
94
  static async open(path) {
72
95
  const s = new DuckdbStore();
@@ -83,10 +106,42 @@ export class DuckdbStore {
83
106
  }
84
107
  // 时区 pin:DuckDB TIMESTAMPTZ->TIMESTAMP 按 session tz 剥离,必须 UTC(与 Rust 客户端一致,否则 bar 翻倍)
85
108
  await s.conn.run(`SET timezone='UTC'`);
109
+ await s.healResidueViews();
86
110
  for (const ddl of SCHEMA_STATEMENTS)
87
111
  await s.conn.run(ddl);
88
112
  return s;
89
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
+ }
90
145
  c() {
91
146
  if (!this.conn)
92
147
  throw new Error('store closed');
@@ -108,7 +163,15 @@ export class DuckdbStore {
108
163
  await this.c().run(sql, params);
109
164
  }
110
165
  async query(sql) {
111
- await this.maybeRefreshKlineSurface();
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
+ });
112
175
  const r = await this.c().run(sql);
113
176
  const cols = r.columnNames();
114
177
  const rows = await r.getRows();
@@ -117,13 +180,13 @@ export class DuckdbStore {
117
180
  /** v2 parquet-direct 查询前置:glob 重展开(view 注册时展开 glob,新文件才
118
181
  * 可见——与服务端 refresh_views 同因)。upsert 模式零开销直通。
119
182
  * sync_meta 读取走原始连接(getSyncMeta → query → 这里,避免自递归)。 */
120
- async maybeRefreshKlineSurface() {
183
+ async maybeRefreshKlineSurfaceCore() {
121
184
  if (!this.parquetRoot)
122
185
  return;
123
186
  const r = await this.c().run(`SELECT value FROM sync_meta WHERE key = '${escapeSql(STORE_MODE_KEY)}'`);
124
187
  const rows = await r.getRows();
125
188
  if (rows[0]?.[0] === 'parquet-direct') {
126
- await this.refreshKlineView(this.parquetRoot);
189
+ await this.refreshKlineViewCore(this.parquetRoot);
127
190
  }
128
191
  }
129
192
  setParquetRoot(root) {
@@ -134,6 +197,18 @@ export class DuckdbStore {
134
197
  * query_engine 同款)。CREATE OR REPLACE 保证幂等。空目录(sync 前置调用)
135
198
  * 建同 schema 空 view,read_parquet 空 glob 会 IO Error。 */
136
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
+ }
137
212
  const glob = `${cacheRoot.replaceAll("'", '')}/kline-compacted/cycle=*/*.parquet`;
138
213
  const r = await this.c().run(`SELECT COUNT(*) AS n FROM glob('${glob}')`);
139
214
  const n = Number((await r.getRows())[0][0]);
@@ -159,16 +234,17 @@ export class DuckdbStore {
159
234
  * (v1 遗留 TABLE 先 DROP);upsert → TABLE(v2 遗留 VIEW 先 DROP 重建,
160
235
  * 服务端回滚场景)。依赖视图 qfq/hfq 一并重建。幂等。 */
161
236
  async ensureKlineSurface(mode, cacheRoot) {
237
+ await this.runExclusive(() => this.ensureKlineSurfaceCore(mode, cacheRoot));
238
+ }
239
+ async ensureKlineSurfaceCore(mode, cacheRoot) {
162
240
  // 原始连接查询(绕过 query() 的 surface 刷新,避免自激)
163
241
  const r = await this.c().run(`SELECT table_type FROM information_schema.tables
164
242
  WHERE table_schema='main' AND table_name='kline'`);
165
243
  const rows = await r.getRows();
166
244
  const ty = rows[0]?.[0];
167
245
  if (mode === 'parquet-direct') {
168
- if (ty === 'BASE TABLE') {
169
- await this.run('DROP VIEW IF EXISTS kline_qfq; DROP VIEW IF EXISTS kline_hfq; DROP TABLE kline;');
170
- }
171
- await this.refreshKlineView(cacheRoot);
246
+ // TABLE→VIEW 拆除在 refreshKlineViewCore 内(heal 后 kline 是空表)
247
+ await this.refreshKlineViewCore(cacheRoot);
172
248
  }
173
249
  else if (ty === 'VIEW') {
174
250
  await this.run('DROP VIEW IF EXISTS kline_qfq; DROP VIEW IF EXISTS kline_hfq; DROP VIEW kline;');
@@ -183,6 +259,62 @@ export class DuckdbStore {
183
259
  const rows = await r.getRows();
184
260
  return rows.length > 0 ? rows[0][0] : null;
185
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
+ }
186
318
  async setSyncMeta(key, value) {
187
319
  await this.run(`INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)`, [key, value]);
188
320
  }
package/dist/sync.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type { DuckdbStore } from './store.js';
2
2
  import type { TickdbHttp } from './http.js';
3
3
  import type { SyncReport } from './types.js';
4
- /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换) -> diff -> stale 删除
5
- * (direct 删文件 / upsert per-file ts 边界删行)-> runPool 并发 fetch+apply
6
- * (mirror-first 全量文件)-> 零错误才写 etag。 */
4
+ /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换,版本门) -> diff
5
+ * -> stale 删除(direct/extra 删文件 / upsert per-file ts 边界删行)->
6
+ * runPool 并发 fetch+apply(mirror-first 全量文件;extra/ 恒 direct)->
7
+ * 零错误才写 etag。 */
7
8
  export declare function syncOnce(store: DuckdbStore, http: TickdbHttp, cacheDir: string, opts?: {
8
9
  downloadConcurrency?: number;
9
10
  }): Promise<SyncReport>;
package/dist/sync.js CHANGED
@@ -2,12 +2,13 @@ import { join } from 'node:path';
2
2
  import { mkdirSync, rmSync } from 'node:fs';
3
3
  import { STORE_MODE_KEY } from './store.js';
4
4
  import { computeDiff } from './diff.js';
5
- import { parseCycleYear, runPool } from './util.js';
5
+ import { isExtraFile, parseCycleYear, runPool } from './util.js';
6
6
  /** v2 嵌套布局(镜像服务端路径,hive cycle 注入依赖 cycle={c} 目录层级)。 */
7
7
  const cachePath = (cacheDir, p) => join(cacheDir, p);
8
- /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换) -> diff -> stale 删除
9
- * (direct 删文件 / upsert per-file ts 边界删行)-> runPool 并发 fetch+apply
10
- * (mirror-first 全量文件)-> 零错误才写 etag。 */
8
+ /** 一轮完整同步:manifest(v1.3 disjoint → 模式判定/切换,版本门) -> diff
9
+ * -> stale 删除(direct/extra 删文件 / upsert per-file ts 边界删行)->
10
+ * runPool 并发 fetch+apply(mirror-first 全量文件;extra/ 恒 direct)->
11
+ * 零错误才写 etag。 */
11
12
  export async function syncOnce(store, http, cacheDir, opts = {}) {
12
13
  const report = {
13
14
  notModified: false, filesTotal: 0, filesFetched: 0, filesUnchanged: 0,
@@ -24,9 +25,17 @@ export async function syncOnce(store, http, cacheDir, opts = {}) {
24
25
  const { manifest, etag: newEtag } = res;
25
26
  report.filesTotal = manifest.files.length;
26
27
  // ① 模式判定 + 切换(v1.3 disjoint → parquet-direct:文件即行集不 upsert)。
27
- const mode = manifest.disjoint === true ? 'parquet-direct' : 'duckdb-upsert';
28
- const direct = mode === 'parquet-direct';
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)。
29
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';
30
39
  if (prevMode !== mode) {
31
40
  // 布局全清重来:v1→v2 旧 flat 文件/表行全废;v2→v1 回退按 manifest 重拉重灌。
32
41
  // 例外:本地无 store_mode(v1 客户端升级)且服务端仍 upsert —— 数据布局
@@ -40,17 +49,24 @@ export async function syncOnce(store, http, cacheDir, opts = {}) {
40
49
  await store.setSyncMeta(STORE_MODE_KEY, mode);
41
50
  }
42
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();
43
57
  await store.ensureKlineSurface(mode, cacheDir);
44
58
  const diff = computeDiff(manifest.files, await store.listLocalManifest());
45
59
  report.filesUnchanged = diff.unchanged;
46
60
  // ② stale 先删(best-effort:单条失败记 error 不中断)。
47
61
  // direct → 删本地文件(行随文件消失);upsert → 优先按该文件自己的 ts 边界删
48
62
  // (月文件消失只删它那个月;边界缺失回退整年删除)。
63
+ // extra/ 前缀在任何模式下都按删文件处理(它是 view 的底座文件,行不在
64
+ // kline 表里——upsert 模式不分流会走 parseCycleYear → 报错)。
49
65
  const localFull = direct ? new Map() : await store.listLocalManifestFull();
50
66
  const staleOk = [];
51
67
  for (const p of diff.stale) {
52
68
  try {
53
- if (direct) {
69
+ if (direct || isExtraFile(p)) {
54
70
  rmSync(cachePath(cacheDir, p), { force: true });
55
71
  }
56
72
  else {
@@ -76,6 +92,9 @@ export async function syncOnce(store, http, cacheDir, opts = {}) {
76
92
  // ③ 并发 fetch + apply(单文件失败不中断整轮)。mirror-first:所有文件
77
93
  // (v1.2 的 frozen gate 移除——上传面已扩到全部文件,sha 校验兜底);
78
94
  // 镜像失败/非 200/sha 不符 → 回落 VPS(带重试),不计 error。
95
+ // extra/ 聚合文件无视入参 direct——upsert 模式客户端也不灌表(聚合
96
+ // parquet 的 schema 不是 kline 形状,误灌会 INSERT 报错;本地由
97
+ // ensureExtraSurfaces 挂 view 直读)。
79
98
  const mirrorBase = manifest.mirror_base_url?.replace(/\/$/, '');
80
99
  await runPool(diff.toFetch, opts.downloadConcurrency ?? 4, async (f) => {
81
100
  try {
@@ -96,8 +115,9 @@ export async function syncOnce(store, http, cacheDir, opts = {}) {
96
115
  if (!viaMirror) {
97
116
  await http.downloadToFile(f.path, f.path, dest, { size: f.size, sha256: f.sha256 });
98
117
  }
99
- if (direct) {
100
- // v2:文件即行集——不 upsert、不删文件
118
+ const fileDirect = direct || isExtraFile(f.path);
119
+ if (fileDirect) {
120
+ // v2/extra:文件即行集——不 upsert、不删文件
101
121
  await store.upsertLocalManifest(f);
102
122
  report.rowsUpserted += 0;
103
123
  }
package/dist/types.d.ts CHANGED
@@ -13,8 +13,11 @@ export interface FileEntry {
13
13
  /** v1.2:文件已冻结上云(内容永不再变,可从镜像下载)。 */
14
14
  frozen?: boolean;
15
15
  }
16
+ /** 支持的 manifest 版本字面量(parseManifest 收窄到这个联合;新增版本
17
+ * 时同步 SUPPORTED_VERSIONS 与 sync.ts 的版本门判定)。 */
18
+ export type ManifestVersion = 'v1' | 'v1.1' | 'v1.2' | 'v1.3' | 'v1.4';
16
19
  export interface Manifest {
17
- version: string;
20
+ version: ManifestVersion;
18
21
  schema: string;
19
22
  generatedAt: string;
20
23
  files: FileEntry[];
package/dist/util.d.ts CHANGED
@@ -4,6 +4,10 @@ export declare function parseCycleYear(path: string): {
4
4
  cycle: string;
5
5
  year: number;
6
6
  };
7
+ /** extra/ 前缀 = 聚合数据集文件(manifest v1.4):恒 direct 分流——
8
+ * 落盘 + view 直读,不进 kline upsert / 按 cycle-year 删行逻辑
9
+ * (对齐 Rust is_extra_file)。 */
10
+ export declare function isExtraFile(p: string): boolean;
7
11
  export declare function yearBounds(year: number): [string, string];
8
12
  export declare function expandHome(p: string): string;
9
13
  export declare const nowSec: () => number;
package/dist/util.js CHANGED
@@ -18,6 +18,12 @@ export function parseCycleYear(path) {
18
18
  throw new Error(`cannot parse cycle/year from manifest path: ${path}`);
19
19
  return { cycle: m[1], year: Number(m[2]) };
20
20
  }
21
+ /** extra/ 前缀 = 聚合数据集文件(manifest v1.4):恒 direct 分流——
22
+ * 落盘 + view 直读,不进 kline upsert / 按 cycle-year 删行逻辑
23
+ * (对齐 Rust is_extra_file)。 */
24
+ export function isExtraFile(p) {
25
+ return p.startsWith('extra/');
26
+ }
21
27
  export function yearBounds(year) {
22
28
  return [`${year}-01-01 00:00:00`, `${year}-12-31 23:59:59`];
23
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickdb-ts",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "offline-first TypeScript client for tickdb (A-share market data): manifest sync + local DuckDB",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",