@benz-ai-x/dsh-24pa 0.2.1-alpha.2.1 → 0.2.1-alpha.2.2
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 -6
- package/README.zh.md +4 -5
- package/lib/config.js +16 -3
- package/lib/db.js +5 -0
- package/lib/pg.js +14 -4
- package/lib/prompts.js +4 -4
- package/lib/repo.js +1 -2
- package/lib/runtime.js +67 -34
- package/lib/sqlite.js +360 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,11 +45,9 @@ Pick the release matching your dsh runtime, then restart the Profile. The reconc
|
|
|
45
45
|
# dsh 0.2.1-alpha.1 host (schedule service built in)
|
|
46
46
|
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.1.6
|
|
47
47
|
|
|
48
|
-
# dsh 0.2.1-alpha.2 host
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
# cannot install the alpha.1 line (the peer check rejects cross-baseline installs)
|
|
52
|
-
# dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.2.1
|
|
48
|
+
# dsh 0.2.1-alpha.2 host (preset synced to standard.patch.yml of that baseline;
|
|
49
|
+
# the peer check rejects cross-baseline installs, so alpha.2 hosts need this line)
|
|
50
|
+
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.2.2
|
|
53
51
|
|
|
54
52
|
# dsh 0.2.0-rc.2 host — line frozen at 0.2.0-rc.2.3, no further releases (also add the two schedule companions for digests)
|
|
55
53
|
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.0-rc.2.3
|
|
@@ -71,7 +69,7 @@ Secrets live only in the server environment; the workspace `AGENTS.md` holds one
|
|
|
71
69
|
| `PA24_WORKSPACE` | Absolute workspace directory bound on first start (or pick one in the panel / dsh settings) |
|
|
72
70
|
| `PA24_FEISHU_APP_ID` / `PA24_FEISHU_APP_SECRET` | Feishu custom-app credentials (feishu mode) |
|
|
73
71
|
|
|
74
|
-
`AGENTS.md` fields: `mode` (demo/feishu), `larkProfile`, `ownerOpenId`, `folderToken`, `tasklistId`, `calendarId`, `calendarChannel`/`todoChannel`/`notifyChannel` (feishu|wecom, default feishu — WeCom as the second operation channel for calendar/todo plus one-way reminder push, never inbound), `timeZone`, `appIdEnv`/`appSecretEnv`/`pgDsnEnv`, `maxWorkers`, `enabledWorkers`, `workerModels`, `extraLocalTools` (closed enum). For Feishu access setup, tell the local session「帮我接通飞书」or call `pa24_connection` with `action=guide`; the bundled `feishu-setup.md` is the single authority.
|
|
72
|
+
`AGENTS.md` fields: `mode` (demo/feishu), `storage` (sqlite/postgres, default sqlite — the ledger is the workspace `data/pa24.db` file; SQLite needs host Node ≥ 23.4; `postgres` requires `pgDsnEnv` and an external database), `larkProfile`, `ownerOpenId`, `folderToken`, `tasklistId`, `calendarId`, `calendarChannel`/`todoChannel`/`notifyChannel` (feishu|wecom, default feishu — WeCom as the second operation channel for calendar/todo plus one-way reminder push, never inbound), `timeZone`, `appIdEnv`/`appSecretEnv`/`pgDsnEnv`, `maxWorkers`, `enabledWorkers`, `workerModels`, `extraLocalTools` (closed enum). For Feishu access setup, tell the local session「帮我接通飞书」or call `pa24_connection` with `action=guide`; the bundled `feishu-setup.md` is the single authority.
|
|
75
73
|
|
|
76
74
|
### Exposing the tool
|
|
77
75
|
|
package/README.zh.md
CHANGED
|
@@ -45,10 +45,9 @@ kind: "package-bundle"
|
|
|
45
45
|
# dsh 0.2.1-alpha.1 宿主(自带 schedule 服务)
|
|
46
46
|
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.1.6
|
|
47
47
|
|
|
48
|
-
# dsh 0.2.1-alpha.2
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
# dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.2.1
|
|
48
|
+
# dsh 0.2.1-alpha.2 宿主(预设已同步该基线的 standard.patch.yml;peer 检查拒绝
|
|
49
|
+
# 跨基线安装,alpha.2 宿主须装此线)
|
|
50
|
+
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.1-alpha.2.2
|
|
52
51
|
|
|
53
52
|
# dsh 0.2.0-rc.2 宿主——该线已冻结于 0.2.0-rc.2.3、不再迭代(简报功能另需两个 schedule 伴随件)
|
|
54
53
|
dsh plugin --profile <name> add @benz-ai-x/dsh-24pa@0.2.0-rc.2.3
|
|
@@ -70,7 +69,7 @@ dsh plugin --profile <name> remove @benz-ai-x/dsh-24pa
|
|
|
70
69
|
| `PA24_WORKSPACE` | 首次启动绑定的绝对工作区目录(也可在面板或设置里选择) |
|
|
71
70
|
| `PA24_FEISHU_APP_ID` / `PA24_FEISHU_APP_SECRET` | 飞书自建应用凭据(feishu 模式) |
|
|
72
71
|
|
|
73
|
-
`AGENTS.md` 字段:`mode`(demo/feishu)、`larkProfile`、`ownerOpenId`、`folderToken`、`tasklistId`、`calendarId`、`calendarChannel`/`todoChannel`/`notifyChannel`(feishu|wecom,默认 feishu——企微作日程/待办第二操作渠道+提醒单向推送,永不收信)、`timeZone`、`appIdEnv`/`appSecretEnv`/`pgDsnEnv`、`maxWorkers`、`enabledWorkers`、`workerModels`、`extraLocalTools`(封闭枚举)。配置飞书接入时,对本地会话说「帮我接通飞书」,或调用 `pa24_connection` 的 `action=guide`;内置 `feishu-setup.md` 是唯一权威。
|
|
72
|
+
`AGENTS.md` 字段:`mode`(demo/feishu)、`storage`(sqlite/postgres,默认 sqlite——账本默认是工作区 `data/pa24.db`,SQLite 要求宿主 Node ≥ 23.4;显式 `postgres` 时才需要 `pgDsnEnv` 与外部数据库)、`larkProfile`、`ownerOpenId`、`folderToken`、`tasklistId`、`calendarId`、`calendarChannel`/`todoChannel`/`notifyChannel`(feishu|wecom,默认 feishu——企微作日程/待办第二操作渠道+提醒单向推送,永不收信)、`timeZone`、`appIdEnv`/`appSecretEnv`/`pgDsnEnv`、`maxWorkers`、`enabledWorkers`、`workerModels`、`extraLocalTools`(封闭枚举)。配置飞书接入时,对本地会话说「帮我接通飞书」,或调用 `pa24_connection` 的 `action=guide`;内置 `feishu-setup.md` 是唯一权威。
|
|
74
73
|
|
|
75
74
|
### 暴露工具
|
|
76
75
|
|
package/lib/config.js
CHANGED
|
@@ -15,6 +15,7 @@ export class ConfigError extends Error {
|
|
|
15
15
|
export const DEFAULT_CONFIG = {
|
|
16
16
|
version: 1,
|
|
17
17
|
mode: 'demo',
|
|
18
|
+
storage: 'sqlite',
|
|
18
19
|
larkProfile: 'default',
|
|
19
20
|
ownerOpenId: '',
|
|
20
21
|
folderToken: '',
|
|
@@ -42,7 +43,6 @@ const STRING_FIELDS = [
|
|
|
42
43
|
'timeZone',
|
|
43
44
|
'appIdEnv',
|
|
44
45
|
'appSecretEnv',
|
|
45
|
-
'pgDsnEnv',
|
|
46
46
|
];
|
|
47
47
|
export function validateConfig(raw) {
|
|
48
48
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
@@ -67,10 +67,22 @@ export function validateConfig(raw) {
|
|
|
67
67
|
}
|
|
68
68
|
if (!input.larkProfile)
|
|
69
69
|
throw new ConfigError('必须固定飞书 CLI profile(larkProfile)。');
|
|
70
|
-
|
|
70
|
+
// F25:账本后端缺省 sqlite;postgres 才要求 pgDsnEnv(存量 PG 工作区升级时显式声明)。
|
|
71
|
+
// `undefined` (older file without the field) takes the product default;
|
|
72
|
+
// an explicit `null` must fail loud, not silently become sqlite.
|
|
73
|
+
const storage = (input.storage === undefined ? 'sqlite' : input.storage);
|
|
74
|
+
if (storage !== 'sqlite' && storage !== 'postgres')
|
|
75
|
+
throw new ConfigError('storage 必须是 sqlite 或 postgres。');
|
|
76
|
+
const dsnEnvKeys = storage === 'postgres'
|
|
77
|
+
? ['appIdEnv', 'appSecretEnv', 'pgDsnEnv']
|
|
78
|
+
: ['appIdEnv', 'appSecretEnv'];
|
|
79
|
+
for (const key of dsnEnvKeys) {
|
|
71
80
|
if (!ENV_NAME.test(input[key]))
|
|
72
81
|
throw new ConfigError(`${key} 应填写环境变量名称(大写字母、数字、下划线)。`);
|
|
73
82
|
}
|
|
83
|
+
if (storage === 'sqlite' && input.pgDsnEnv !== undefined && !ENV_NAME.test(input.pgDsnEnv)) {
|
|
84
|
+
throw new ConfigError('pgDsnEnv 应填写环境变量名称(大写字母、数字、下划线)。');
|
|
85
|
+
}
|
|
74
86
|
const maxWorkers = input.maxWorkers;
|
|
75
87
|
if (!Number.isInteger(maxWorkers) || maxWorkers < 1 || maxWorkers > 8) {
|
|
76
88
|
throw new ConfigError('maxWorkers 必须是 1–8 的整数。');
|
|
@@ -110,6 +122,7 @@ export function validateConfig(raw) {
|
|
|
110
122
|
const config = {
|
|
111
123
|
version: 1,
|
|
112
124
|
mode: input.mode,
|
|
125
|
+
storage,
|
|
113
126
|
larkProfile: input.larkProfile,
|
|
114
127
|
ownerOpenId: input.ownerOpenId,
|
|
115
128
|
folderToken: input.folderToken,
|
|
@@ -121,7 +134,7 @@ export function validateConfig(raw) {
|
|
|
121
134
|
timeZone: input.timeZone,
|
|
122
135
|
appIdEnv: input.appIdEnv,
|
|
123
136
|
appSecretEnv: input.appSecretEnv,
|
|
124
|
-
pgDsnEnv: input.pgDsnEnv,
|
|
137
|
+
pgDsnEnv: (input.pgDsnEnv ?? 'PA24_PG_DSN'),
|
|
125
138
|
maxWorkers: maxWorkers,
|
|
126
139
|
enabledWorkers: enabledWorkers,
|
|
127
140
|
workerModels: workerModels,
|
package/lib/db.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Backend-agnostic database surface (F25): the business ledger runs on
|
|
2
|
+
// SQLite by default or PostgreSQL by explicit workspace choice, and repos
|
|
3
|
+
// program against this interface only. No silent fallback — whichever
|
|
4
|
+
// backend the config names is the backend that runs, or startup fails loud.
|
|
5
|
+
export {};
|
package/lib/pg.js
CHANGED
|
@@ -3,7 +3,7 @@ import pg from 'pg';
|
|
|
3
3
|
// PostgreSQL instance. The DSN itself comes from the environment variable
|
|
4
4
|
// named by AGENTS.md (`pgDsnEnv`); credentials never enter workspace files.
|
|
5
5
|
export const SCHEMA_NAME = 'pa24';
|
|
6
|
-
const MIGRATIONS = [
|
|
6
|
+
export const MIGRATIONS = [
|
|
7
7
|
{
|
|
8
8
|
version: 1,
|
|
9
9
|
statements: [
|
|
@@ -425,6 +425,7 @@ const MIGRATIONS = [
|
|
|
425
425
|
},
|
|
426
426
|
];
|
|
427
427
|
export class PaDatabase {
|
|
428
|
+
kind = 'postgres';
|
|
428
429
|
pool;
|
|
429
430
|
closed = false;
|
|
430
431
|
constructor(dsn, options = {}) {
|
|
@@ -474,10 +475,15 @@ export class PaDatabase {
|
|
|
474
475
|
const result = await this.pool.query(`select coalesce(max(version), 0) as version from ${SCHEMA_NAME}.schema_migrations`);
|
|
475
476
|
return result.rows[0].version;
|
|
476
477
|
}
|
|
477
|
-
query(text, values) {
|
|
478
|
+
async query(text, values) {
|
|
478
479
|
if (this.closed)
|
|
479
|
-
|
|
480
|
-
|
|
480
|
+
throw new Error('数据库连接池已关闭。');
|
|
481
|
+
const result = await this.pool.query(text, values);
|
|
482
|
+
// PaDb contract: rowCount is always a number (the driver leaves it null
|
|
483
|
+
// only in cases our repos never rely on).
|
|
484
|
+
if (result.rowCount == null)
|
|
485
|
+
return { ...result, rowCount: 0 };
|
|
486
|
+
return result;
|
|
481
487
|
}
|
|
482
488
|
async withTransaction(work) {
|
|
483
489
|
const client = await this.pool.connect();
|
|
@@ -495,6 +501,10 @@ export class PaDatabase {
|
|
|
495
501
|
client.release();
|
|
496
502
|
}
|
|
497
503
|
}
|
|
504
|
+
async nextNoteSeq() {
|
|
505
|
+
const result = await this.pool.query(`select nextval('pa24.note_seq') as next`);
|
|
506
|
+
return Number(result.rows[0].next);
|
|
507
|
+
}
|
|
498
508
|
async close() {
|
|
499
509
|
if (this.closed)
|
|
500
510
|
return;
|
package/lib/prompts.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 职责/完成标准/边界/输出要求, and every copy carries a version constant so a
|
|
8
8
|
// prompt change is reviewable against a bump (PROMPTS_VERSION).
|
|
9
9
|
/** Semantic version of the whole prompt set; bump on any copy change. */
|
|
10
|
-
export const PROMPTS_VERSION = '1.
|
|
10
|
+
export const PROMPTS_VERSION = '1.5.0';
|
|
11
11
|
export const LEAD_SECTIONS = [
|
|
12
12
|
{
|
|
13
13
|
name: '24pa-identity-capability',
|
|
@@ -144,7 +144,7 @@ export const WORKER_PROMPTS = {
|
|
|
144
144
|
].join('\n'),
|
|
145
145
|
brief: 'calendar_query/calendar_busy(同步水位/冲突/新鲜度)、calendar_create/update/cancel(staged 幂等)、meeting_schedule、plan_today/plan_preview/plan_adopt、meeting_prep_*、overview_today。',
|
|
146
146
|
doneCriteria: '读操作带新鲜度说明;写操作取得平台回执(日程标识)后交回;规划建议按事实/建议/来源/缺失四栏给出。',
|
|
147
|
-
version:
|
|
147
|
+
version: 2,
|
|
148
148
|
},
|
|
149
149
|
reminders: {
|
|
150
150
|
persona: [
|
|
@@ -155,7 +155,7 @@ export const WORKER_PROMPTS = {
|
|
|
155
155
|
'- 时间计算由宿主的 dsh-schedule 公开函数完成,不自行推算。',
|
|
156
156
|
'- 只报告平台接受状态,不推断已读。',
|
|
157
157
|
'## 边界',
|
|
158
|
-
'-
|
|
158
|
+
'- 提醒由业务账本触发实例并经 Outbox 投递,模型离线也能发出。',
|
|
159
159
|
'- 完成/稍后/取消都绑定原规则与实例,重复请求不产生多份。',
|
|
160
160
|
'- 不执行其他业务,不产生新授权。',
|
|
161
161
|
'## 输出要求',
|
|
@@ -267,6 +267,6 @@ ${defaultConfigJson}
|
|
|
267
267
|
- 需要个人偏好或项目事实时,先查询结构化记忆(随后续功能启用),保留来源和确认状态。
|
|
268
268
|
- 配置与记忆维护通过 dsh 的24私助会话进行;飞书接入会话与 Worker 没有维护写入权限。
|
|
269
269
|
- 飞书接入按内置指南分阶段配置:先 pa24_connection action=guide 通读,再按 action=check 返回的 nextSteps 逐项收敛;应用密钥与 CLI 凭据只由本人在本人终端/启动环境填写。
|
|
270
|
-
-
|
|
270
|
+
- 业务账本按工作区配置运行(默认 SQLite,可选 PostgreSQL);账本不可用时停止接纳相关业务,不伪造成功。
|
|
271
271
|
`;
|
|
272
272
|
}
|
package/lib/repo.js
CHANGED
|
@@ -499,8 +499,7 @@ export class NoteRepo {
|
|
|
499
499
|
this.db = db;
|
|
500
500
|
}
|
|
501
501
|
async nextNoteId() {
|
|
502
|
-
|
|
503
|
-
return `N-${Number(result.rows[0].next)}`;
|
|
502
|
+
return `N-${await this.db.nextNoteSeq()}`;
|
|
504
503
|
}
|
|
505
504
|
async insertNote(row) {
|
|
506
505
|
const result = await this.db.query(`insert into pa24.note (id, title, status, origin, work_item_id) values ($1, $2, 'collecting', $3, $4) returning *`, [row.id, row.title, row.origin, row.workItemId ?? null]);
|
package/lib/runtime.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID, createHash } from 'node:crypto';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { mkdir, realpath, stat, readFile } from 'node:fs/promises';
|
|
4
|
-
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
5
5
|
import { PaDatabase, resolveDsn } from './pg.js';
|
|
6
|
+
import { SqliteDb } from './sqlite.js';
|
|
6
7
|
import { createRepos } from './repo.js';
|
|
7
8
|
import { acquireHostLock } from './lock.js';
|
|
8
9
|
import { parseAgentsMd, template, ConfigError } from './config.js';
|
|
@@ -195,7 +196,7 @@ export class PaRuntime {
|
|
|
195
196
|
occurred_on: new Date().toISOString().slice(0, 10),
|
|
196
197
|
});
|
|
197
198
|
await this.repos.workItems.update(item.id, { result_ref: { memoId: memo.id, operationId: `digest:${item.id}` } });
|
|
198
|
-
return { memoId: memo.id, topic, message: '
|
|
199
|
+
return { memoId: memo.id, topic, message: '资料摘要已保存入业务账本,可按主题检索。' };
|
|
199
200
|
},
|
|
200
201
|
};
|
|
201
202
|
registerBuiltInRoles() {
|
|
@@ -717,9 +718,18 @@ export class PaRuntime {
|
|
|
717
718
|
await this.connectTransport();
|
|
718
719
|
}
|
|
719
720
|
async openDatabaseFor(workspacePath) {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
721
|
+
const probe = await this.probeStorageConfig(workspacePath);
|
|
722
|
+
// F25: default ledger is a SQLite file in the workspace; PostgreSQL runs
|
|
723
|
+
// only by explicit workspace choice. No silent fallback either way.
|
|
724
|
+
let db;
|
|
725
|
+
if (probe.storage === 'postgres') {
|
|
726
|
+
db = new PaDatabase(resolveDsn(probe.pgDsnEnv, this.env));
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
const dataDir = join(workspacePath, 'data');
|
|
730
|
+
await mkdir(dataDir, { recursive: true });
|
|
731
|
+
db = new SqliteDb(join(dataDir, 'pa24.db'));
|
|
732
|
+
}
|
|
723
733
|
try {
|
|
724
734
|
await db.check();
|
|
725
735
|
await db.migrate();
|
|
@@ -732,14 +742,21 @@ export class PaRuntime {
|
|
|
732
742
|
this.db = db;
|
|
733
743
|
this.repos = createRepos(db);
|
|
734
744
|
}
|
|
735
|
-
/** Read the
|
|
736
|
-
async
|
|
745
|
+
/** Read the storage choice from AGENTS.md before the full config is trusted. */
|
|
746
|
+
async probeStorageConfig(workspacePath) {
|
|
737
747
|
try {
|
|
738
748
|
const source = await readFile(join(workspacePath, 'AGENTS.md'), 'utf8');
|
|
739
|
-
|
|
749
|
+
const { config } = parseAgentsMd(source);
|
|
750
|
+
return { storage: config.storage, pgDsnEnv: config.pgDsnEnv };
|
|
740
751
|
}
|
|
741
|
-
catch {
|
|
742
|
-
|
|
752
|
+
catch (error) {
|
|
753
|
+
// An INVALID config block fails loud right here — before any ledger
|
|
754
|
+
// side effect (openDatabaseFor would otherwise migrate a file the
|
|
755
|
+
// broken workspace never asked for). A missing/unreadable AGENTS.md
|
|
756
|
+
// (fresh workspace) takes the zero-dependency product default.
|
|
757
|
+
if (error instanceof ConfigError)
|
|
758
|
+
throw error;
|
|
759
|
+
return { storage: 'sqlite', pgDsnEnv: 'PA24_PG_DSN' };
|
|
743
760
|
}
|
|
744
761
|
}
|
|
745
762
|
async loadConfig() {
|
|
@@ -799,7 +816,7 @@ export class PaRuntime {
|
|
|
799
816
|
if (!this.config)
|
|
800
817
|
return;
|
|
801
818
|
if (this.config.mode !== 'feishu') {
|
|
802
|
-
this.transportError = 'demo
|
|
819
|
+
this.transportError = 'demo:未连接飞书;业务账本按工作区配置运行。';
|
|
803
820
|
return;
|
|
804
821
|
}
|
|
805
822
|
const appId = this.env[this.config.appIdEnv];
|
|
@@ -1065,7 +1082,7 @@ export class PaRuntime {
|
|
|
1065
1082
|
await this.notifyOwner(`status:${event.eventId}`, [
|
|
1066
1083
|
'24私助已收到你的消息。',
|
|
1067
1084
|
`绑定:${this.config.ownerOpenId ? '已按配置绑定主人' : '尚未在配置中绑定主人'}`,
|
|
1068
|
-
|
|
1085
|
+
`业务账本(${this.db?.kind === 'sqlite' ? 'SQLite' : 'PostgreSQL'}):${pg?.state === 'ok' ? '正常' : pg?.message ?? '未知'}`,
|
|
1069
1086
|
'直接告诉我需要办理什么;也可以说“查看正在处理的事”。配置与记忆维护请到 dsh 的24私助会话。',
|
|
1070
1087
|
].join('\n'));
|
|
1071
1088
|
await this.repos.inbox.mark(row.event_id, { status: 'delivered', requestId: `status:${event.eventId}` });
|
|
@@ -2625,7 +2642,7 @@ export class PaRuntime {
|
|
|
2625
2642
|
operationId,
|
|
2626
2643
|
docUrl: external.url,
|
|
2627
2644
|
demo: !live,
|
|
2628
|
-
message: live && external.docId ? '备忘已保存为飞书文档并回读确认。' : '
|
|
2645
|
+
message: live && external.docId ? '备忘已保存为飞书文档并回读确认。' : '演示模式:备忘已入业务账本,未创建飞书文档。',
|
|
2629
2646
|
};
|
|
2630
2647
|
}
|
|
2631
2648
|
catch (error) {
|
|
@@ -4208,26 +4225,40 @@ export class PaRuntime {
|
|
|
4208
4225
|
await wf(join(dir, name), bytes);
|
|
4209
4226
|
parts.push({ id, path: name, bytes: bytes.length, sha256: sha256Hex(bytes) });
|
|
4210
4227
|
};
|
|
4211
|
-
// 1.
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
+
// 1. Ledger backup: PG dump, or a checkpointed SQLite file copy (F25).
|
|
4229
|
+
if (this.db.kind === 'sqlite') {
|
|
4230
|
+
const sqlite = this.db;
|
|
4231
|
+
const { busy } = sqlite.checkpoint();
|
|
4232
|
+
await addPart('sqlite', 'pa24.db', await rf(sqlite.filePath));
|
|
4233
|
+
// A busy checkpoint means frames stayed in the WAL — ship it too, or
|
|
4234
|
+
// the backup would silently miss committed writes.
|
|
4235
|
+
if (busy > 0) {
|
|
4236
|
+
await addPart('sqlite-wal', 'pa24.db-wal', await rf(`${sqlite.filePath}-wal`)).catch(async () => {
|
|
4237
|
+
// -wal absent between checkpoints: nothing to append.
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
else {
|
|
4242
|
+
const dsn = resolveDsn(this.config.pgDsnEnv, this.env);
|
|
4243
|
+
const dump = await new Promise((resolve, reject) => {
|
|
4244
|
+
// DSN via env keeps it out of the process argv; stderr is surfaced.
|
|
4245
|
+
const child = spawn('pg_dump', ['--schema=pa24', '--no-owner', '--no-privileges', '--dbname', dsn], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
4246
|
+
const chunks = [];
|
|
4247
|
+
let stderr = '';
|
|
4248
|
+
child.stdout.on('data', (c) => chunks.push(c));
|
|
4249
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
4250
|
+
child.on('error', reject);
|
|
4251
|
+
child.on('close', code => {
|
|
4252
|
+
if (code === 0)
|
|
4253
|
+
resolve(Buffer.concat(chunks));
|
|
4254
|
+
else {
|
|
4255
|
+
const detail = stderr.split(dsn).join('[已隐藏]').slice(0, 300) || '无诊断输出';
|
|
4256
|
+
reject(new Error(`pg_dump 退出码 ${code}:${detail}`));
|
|
4257
|
+
}
|
|
4258
|
+
});
|
|
4228
4259
|
});
|
|
4229
|
-
|
|
4230
|
-
|
|
4260
|
+
await addPart('postgres', 'pa24.sql', dump);
|
|
4261
|
+
}
|
|
4231
4262
|
// 2. Workspace (AGENTS.md + .24pa memory/revisions/originals/crops) and
|
|
4232
4263
|
// dsh state: real file CONTENTS are copied into the backup, not just
|
|
4233
4264
|
// listings — restore copies them back verbatim (P38).
|
|
@@ -4316,7 +4347,7 @@ export class PaRuntime {
|
|
|
4316
4347
|
complete: failures.length === 0,
|
|
4317
4348
|
failures,
|
|
4318
4349
|
message: failures.length === 0
|
|
4319
|
-
? '
|
|
4350
|
+
? `备份完整(账本${this.db?.kind === 'sqlite' ? ' SQLite 文件' : ' PG 转储'}与全部文件内容逐个摘要匹配)。恢复顺序:先把账本恢复到数据目录(SQLite=复制 pa24.db 到工作区 data/,备份若含 pa24.db-wal 一并放回;PG=psql < pa24.sql),再按 workspace/dsh-state 清单把文件内容复制回原路径,重启 Host 后按启动对账处理;旧 Outbox 与飞书实际对象先对账再发送。`
|
|
4320
4351
|
: `备份不完整(${failures.join(';')});对应部分恢复后不可视为就绪。`,
|
|
4321
4352
|
};
|
|
4322
4353
|
}
|
|
@@ -5499,7 +5530,9 @@ export class PaRuntime {
|
|
|
5499
5530
|
id: 'postgres',
|
|
5500
5531
|
state: this.db ? 'ok' : 'error',
|
|
5501
5532
|
message: this.db
|
|
5502
|
-
?
|
|
5533
|
+
? this.db.kind === 'sqlite'
|
|
5534
|
+
? `SQLite 业务账本已连接(${basename(this.db.filePath ?? '')})`
|
|
5535
|
+
: 'PostgreSQL 业务账本已连接'
|
|
5503
5536
|
: (this.configError && this.configError.includes('数据库') ? this.configError : 'PostgreSQL 未连接'),
|
|
5504
5537
|
});
|
|
5505
5538
|
items.push({
|
package/lib/sqlite.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { MIGRATIONS } from './pg.js';
|
|
4
|
+
// F25: SQLite backend for the business ledger (default storage; PostgreSQL
|
|
5
|
+
// stays available by explicit `storage: "postgres"`). The repos keep their
|
|
6
|
+
// PG-dialect SQL untouched — this adapter translates it on the fly:
|
|
7
|
+
// pa24.<table> -> pa24_<table> (no schemas in SQLite)
|
|
8
|
+
// $1..$n -> ? (positional, validated)
|
|
9
|
+
// = any($n) -> in (?, ?, …) (array param expansion)
|
|
10
|
+
// $n::type casts -> $n (params are pre-encoded)
|
|
11
|
+
// now() -> strftime ISO UTC (same shape pg hands back)
|
|
12
|
+
// now() + make_interval -> strftime + printf seconds
|
|
13
|
+
// ilike -> like (ASCII-insensitive both)
|
|
14
|
+
// for update skip locked-> (stripped; single serial writer, no locks needed)
|
|
15
|
+
// Unknown leftovers fail loud at translate time — never silently misrun.
|
|
16
|
+
// DDL comes from the SAME migration list as PostgreSQL (pg.ts), transformed
|
|
17
|
+
// per backend type mapping, so the two schemas cannot drift apart.
|
|
18
|
+
const ISO_NOW = `strftime('%Y-%m-%dT%H:%M:%fZ','now')`;
|
|
19
|
+
// ---- DDL translation ---------------------------------------------------------
|
|
20
|
+
function translateDdl(stmt) {
|
|
21
|
+
if (/^create sequence/i.test(stmt)) {
|
|
22
|
+
// PG sequence -> counter table + seed row (nextNoteSeq updates it).
|
|
23
|
+
return [
|
|
24
|
+
`create table if not exists pa24_note_seq (id integer primary key check (id = 1), next integer not null)`,
|
|
25
|
+
`insert or ignore into pa24_note_seq (id, next) values (1, 1)`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
let sql = stmt
|
|
29
|
+
.replace(/\bpa24\./g, 'pa24_')
|
|
30
|
+
.replace(/\btimestamptz\b/g, 'text')
|
|
31
|
+
.replace(/'([^']*)'::jsonb/g, "'$1'")
|
|
32
|
+
.replace(/\bjsonb\b/g, 'text')
|
|
33
|
+
.replace(/\bboolean\b/g, 'integer')
|
|
34
|
+
.replace(/\bbigserial\b/g, 'integer')
|
|
35
|
+
.replace(/default now\(\)/g, `default (${ISO_NOW})`)
|
|
36
|
+
.replace(/\bdate\b(?=\s*,)/g, 'text')
|
|
37
|
+
.replace(/add column if not exists/g, 'add column');
|
|
38
|
+
// bigserial id becomes the autoincrement rowid alias.
|
|
39
|
+
if (/\bid integer primary key\b/.test(sql))
|
|
40
|
+
sql = sql.replace(/\bid integer primary key\b/, 'id integer primary key autoincrement');
|
|
41
|
+
sql = sql.replace(/default false/g, 'default 0').replace(/default true/g, 'default 1');
|
|
42
|
+
// Fail-loud symmetry with query translation: an untranslated PG type means
|
|
43
|
+
// the mapping rules drifted, never silently mis-typed columns.
|
|
44
|
+
const leftover = /timestamptz|jsonb|\bboolean\b|\bbigserial\b|\bsequence\b|nextval/i.exec(sql);
|
|
45
|
+
if (leftover) {
|
|
46
|
+
throw new Error(`SQLite DDL 翻译遇到未处理的 PostgreSQL 类型「${leftover[0]}」。语句:${stmt}`);
|
|
47
|
+
}
|
|
48
|
+
return [sql];
|
|
49
|
+
}
|
|
50
|
+
function splitTopLevel(body) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let current = '';
|
|
54
|
+
for (const ch of body) {
|
|
55
|
+
if (ch === '(')
|
|
56
|
+
depth += 1;
|
|
57
|
+
if (ch === ')')
|
|
58
|
+
depth -= 1;
|
|
59
|
+
if (ch === ',' && depth === 0) {
|
|
60
|
+
parts.push(current);
|
|
61
|
+
current = '';
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
current += ch;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current.trim())
|
|
68
|
+
parts.push(current);
|
|
69
|
+
return parts.map(p => p.trim()).filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
function inferKinds() {
|
|
72
|
+
const kinds = {};
|
|
73
|
+
const learn = (table, name, type) => {
|
|
74
|
+
const kind = /jsonb/.test(type) ? 'json' : /boolean/.test(type) ? 'bool' : /timestamptz/.test(type) ? 'ts' : null;
|
|
75
|
+
if (!kind)
|
|
76
|
+
return;
|
|
77
|
+
(kinds[table] ??= {})[name] = kind;
|
|
78
|
+
};
|
|
79
|
+
for (const migration of MIGRATIONS) {
|
|
80
|
+
for (const stmt of migration.statements) {
|
|
81
|
+
let m = /^create table (?:if not exists )?pa24\.(\w+)\s*\(([\s\S]*)\)\s*$/.exec(stmt.trim());
|
|
82
|
+
if (m) {
|
|
83
|
+
// Trim the outer parens content carefully: the regex above is greedy —
|
|
84
|
+
// rebuild via first '(' to last ')'.
|
|
85
|
+
const open = stmt.indexOf('(');
|
|
86
|
+
const close = stmt.lastIndexOf(')');
|
|
87
|
+
const body = stmt.slice(open + 1, close);
|
|
88
|
+
const table = m[1];
|
|
89
|
+
let learned = 0;
|
|
90
|
+
for (const col of splitTopLevel(body)) {
|
|
91
|
+
const cm = /^(\w+)\s+(.+)$/.exec(col);
|
|
92
|
+
if (!cm || /^(primary|foreign|unique|check|constraint)\b/i.test(cm[1]))
|
|
93
|
+
continue;
|
|
94
|
+
learn(table, cm[1], cm[2]);
|
|
95
|
+
learned += 1;
|
|
96
|
+
}
|
|
97
|
+
// Fail loud, symmetric with query translation: a matched CREATE TABLE
|
|
98
|
+
// that parsed to zero columns means the DDL shape drifted.
|
|
99
|
+
if (learned === 0)
|
|
100
|
+
throw new Error(`SQLite 列类型推导失败:pa24.${table} 未解析出任何列。`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
m = /^alter table pa24\.(\w+) add column (?:if not exists )?(\w+)\s+(.+)$/.exec(stmt.trim());
|
|
104
|
+
if (m)
|
|
105
|
+
learn(m[1], m[2], m[3]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return kinds;
|
|
109
|
+
}
|
|
110
|
+
const COLUMN_KINDS = inferKinds();
|
|
111
|
+
const ANY_MARKER = (n, op) => `«ANY_${n}:${op}»`;
|
|
112
|
+
export function translateQuery(inputSql, inputValues) {
|
|
113
|
+
let sql = inputSql;
|
|
114
|
+
// Row locks are meaningless under one serial writer.
|
|
115
|
+
sql = sql.replace(/for update skip locked/gi, '').replace(/for update/gi, '');
|
|
116
|
+
// Array membership FIRST — the optional cast suffix ($n::text[]) would be
|
|
117
|
+
// stripped by the cast rule below and orphan the marker pattern.
|
|
118
|
+
sql = sql.replace(/=\s*any\(\$(\d+)(?:::[a-z[\]]*)?\)/gi, (_all, n) => ANY_MARKER(Number(n), 'in'));
|
|
119
|
+
sql = sql.replace(/(?:<>|!=)\s*all\(\$(\d+)(?:::[a-z[\]]*)?\)/gi, (_all, n) => ANY_MARKER(Number(n), 'not in'));
|
|
120
|
+
// $n::type casts — params arrive pre-encoded; unknown cast types are left
|
|
121
|
+
// in place and rejected by the leftover validator below.
|
|
122
|
+
sql = sql.replace(/(\$\d+|\))::(?:jsonb|json|text|bool|date|int|integer|timestamptz)\b/gi, '$1');
|
|
123
|
+
// BASE + make_interval(secs => $n): BASE is now() or a $n timestamp —
|
|
124
|
+
// both become strftime with a printf'd seconds modifier so the result
|
|
125
|
+
// keeps the ISO shape stored in timestamptz columns (string comparison
|
|
126
|
+
// across formats would silently break window queries).
|
|
127
|
+
sql = sql.replace(/(now\(\)|\$\d+)\s*\+\s*make_interval\(secs\s*=>\s*\$(\d+)\)/gi, (_all, base, n) => `strftime('%Y-%m-%dT%H:%M:%fZ', ${base === 'now()' ? "'now'" : base}, printf('+%.0f seconds', $${n}))`);
|
|
128
|
+
sql = sql.replace(/\bnow\(\)/g, ISO_NOW);
|
|
129
|
+
sql = sql.replace(/\bilike\b/gi, 'like');
|
|
130
|
+
sql = sql.replace(/\bpa24\./g, 'pa24_');
|
|
131
|
+
// Mark array membership before the positional walk; the marker expands in
|
|
132
|
+
// place so the array's elements land in the right parameter positions.
|
|
133
|
+
// $n -> ? by appearance order; reusing a number (e.g. "a ilike $5 or b
|
|
134
|
+
// ilike $5") is legal PG and re-binds the same value. Params bind by the
|
|
135
|
+
// ORIGINAL numbering, so any() splices its elements where it appears.
|
|
136
|
+
const values = [];
|
|
137
|
+
sql = sql.replace(/\$(\d+)|«ANY_(\d+):(in|not in)»/g, (_all, num, anyNum, op) => {
|
|
138
|
+
if (anyNum !== undefined) {
|
|
139
|
+
const arr = inputValues[Number(anyNum) - 1];
|
|
140
|
+
if (!Array.isArray(arr))
|
|
141
|
+
throw new Error(`SQLite 适配:$${anyNum} 应为数组(any/all)。SQL:${inputSql}`);
|
|
142
|
+
const keyword = op === 'not in' ? 'not in' : 'in';
|
|
143
|
+
// PG semantics: <> ALL over an EMPTY array is true; IN over empty is false.
|
|
144
|
+
if (arr.length === 0)
|
|
145
|
+
return keyword === 'not in' ? '1 = 1' : 'in (select 1 where 0)';
|
|
146
|
+
values.push(...arr);
|
|
147
|
+
return `${keyword} (${arr.map(() => '?').join(', ')})`;
|
|
148
|
+
}
|
|
149
|
+
const n = Number(num);
|
|
150
|
+
if (n < 1 || n > inputValues.length) {
|
|
151
|
+
throw new Error(`SQLite 适配:占位符 $${n} 超出参数范围(${inputValues.length} 个)。SQL:${inputSql}`);
|
|
152
|
+
}
|
|
153
|
+
values.push(inputValues[n - 1]);
|
|
154
|
+
return '?';
|
|
155
|
+
});
|
|
156
|
+
const leftover = /\bilike\b|::|\bany\(|\ball\(|\bnow\(\)|nextval|for update|make_interval/i.exec(sql);
|
|
157
|
+
if (leftover) {
|
|
158
|
+
throw new Error(`SQLite 适配遇到未处理的 PostgreSQL 方言「${leftover[0]}」。SQL:${inputSql}`);
|
|
159
|
+
}
|
|
160
|
+
return { sql: sql.trim(), values };
|
|
161
|
+
}
|
|
162
|
+
function encodeParam(value) {
|
|
163
|
+
if (value === undefined)
|
|
164
|
+
return null;
|
|
165
|
+
if (value === null || typeof value === 'string' || typeof value === 'number')
|
|
166
|
+
return value;
|
|
167
|
+
if (typeof value === 'boolean')
|
|
168
|
+
return value ? 1 : 0;
|
|
169
|
+
if (value instanceof Date)
|
|
170
|
+
return value.toISOString();
|
|
171
|
+
if (typeof value === 'object')
|
|
172
|
+
return JSON.stringify(value);
|
|
173
|
+
return String(value);
|
|
174
|
+
}
|
|
175
|
+
function coerceRows(table, rows) {
|
|
176
|
+
const kinds = table ? COLUMN_KINDS[table] : null;
|
|
177
|
+
if (!kinds)
|
|
178
|
+
return rows;
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
for (const [column, kind] of Object.entries(kinds)) {
|
|
181
|
+
const value = row[column];
|
|
182
|
+
if (value === null || value === undefined)
|
|
183
|
+
continue;
|
|
184
|
+
if (kind === 'json' && typeof value === 'string') {
|
|
185
|
+
try {
|
|
186
|
+
row[column] = JSON.parse(value);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Leave as-is: a malformed value surfaces where pg would have too.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (kind === 'bool') {
|
|
193
|
+
row[column] = value === 1 || value === true;
|
|
194
|
+
}
|
|
195
|
+
else if (kind === 'ts' && typeof value === 'string') {
|
|
196
|
+
row[column] = new Date(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
function tableOf(sql) {
|
|
203
|
+
const m = /\b(?:from|into|update)\s+pa24_(\w+)/i.exec(sql);
|
|
204
|
+
return m?.[1] ?? null;
|
|
205
|
+
}
|
|
206
|
+
export class SqliteDb {
|
|
207
|
+
kind = 'sqlite';
|
|
208
|
+
filePath;
|
|
209
|
+
db;
|
|
210
|
+
closed = false;
|
|
211
|
+
/** Serializes every statement; a transaction holds it until commit. */
|
|
212
|
+
lock = Promise.resolve();
|
|
213
|
+
txOwner = new AsyncLocalStorage();
|
|
214
|
+
constructor(filePath) {
|
|
215
|
+
this.filePath = filePath;
|
|
216
|
+
let DatabaseSync;
|
|
217
|
+
try {
|
|
218
|
+
({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite'));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error('SQLite 模式需要 Node 内建 node:sqlite(Node ≥ 23.4,或 22.x 加 --experimental-sqlite);' +
|
|
222
|
+
'当前运行时不支持。可改用 storage: "postgres",或升级宿主 Node。');
|
|
223
|
+
}
|
|
224
|
+
this.db = new DatabaseSync(filePath);
|
|
225
|
+
this.db.exec('pragma journal_mode = wal');
|
|
226
|
+
this.db.exec('pragma foreign_keys = on');
|
|
227
|
+
this.db.exec('pragma busy_timeout = 5000');
|
|
228
|
+
}
|
|
229
|
+
enqueue(job) {
|
|
230
|
+
const run = this.lock.then(job, job);
|
|
231
|
+
this.lock = run.catch(() => undefined);
|
|
232
|
+
return run;
|
|
233
|
+
}
|
|
234
|
+
runTranslated(text, values) {
|
|
235
|
+
const { sql, values: encoded } = translateQuery(text, values);
|
|
236
|
+
const stmt = this.db.prepare(sql);
|
|
237
|
+
const params = encoded.map(encodeParam);
|
|
238
|
+
const returnsRows = /^select\b/i.test(sql) || /\breturning\b/i.test(sql);
|
|
239
|
+
if (returnsRows) {
|
|
240
|
+
const rows = coerceRows(tableOf(sql), stmt.all(...params));
|
|
241
|
+
return { rows, rowCount: rows.length };
|
|
242
|
+
}
|
|
243
|
+
const { changes } = stmt.run(...params);
|
|
244
|
+
return { rows: [], rowCount: Number(changes ?? 0) };
|
|
245
|
+
}
|
|
246
|
+
query(text, values = []) {
|
|
247
|
+
if (this.closed)
|
|
248
|
+
return Promise.reject(new Error('SQLite 账本已关闭。'));
|
|
249
|
+
// Inside the owning transaction the statement must join it, not queue
|
|
250
|
+
// behind the lock the transaction itself holds.
|
|
251
|
+
if (this.txOwner.getStore()?.active) {
|
|
252
|
+
return Promise.resolve(this.runTranslated(text, values));
|
|
253
|
+
}
|
|
254
|
+
return this.enqueue(() => Promise.resolve(this.runTranslated(text, values)));
|
|
255
|
+
}
|
|
256
|
+
async check() {
|
|
257
|
+
await this.query('select 1 as ok');
|
|
258
|
+
}
|
|
259
|
+
async migrate() {
|
|
260
|
+
return this.enqueue(async () => {
|
|
261
|
+
this.db.exec(`create table if not exists pa24_schema_migrations (version integer primary key, applied_at text not null default (${ISO_NOW}))`);
|
|
262
|
+
const current = this.runTranslated('select coalesce(max(version), 0) as version from pa24.schema_migrations', []);
|
|
263
|
+
let applied = Number(current.rows[0].version);
|
|
264
|
+
for (const migration of MIGRATIONS) {
|
|
265
|
+
if (migration.version <= applied)
|
|
266
|
+
continue;
|
|
267
|
+
this.db.exec('begin');
|
|
268
|
+
try {
|
|
269
|
+
for (const statement of migration.statements) {
|
|
270
|
+
for (const translated of translateDdl(statement))
|
|
271
|
+
this.db.exec(translated);
|
|
272
|
+
}
|
|
273
|
+
this.db.exec(`insert into pa24_schema_migrations (version) values (${migration.version})`);
|
|
274
|
+
this.db.exec('commit');
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.db.exec('rollback');
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
applied = migration.version;
|
|
281
|
+
}
|
|
282
|
+
return applied;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async schemaVersion() {
|
|
286
|
+
const result = await this.query('select coalesce(max(version), 0) as version from pa24.schema_migrations');
|
|
287
|
+
return Number(result.rows[0].version);
|
|
288
|
+
}
|
|
289
|
+
async nextNoteSeq() {
|
|
290
|
+
const result = await this.query(`update pa24.note_seq set next = next + 1 where id = 1 returning next - 1 as next`);
|
|
291
|
+
if (!result.rows[0])
|
|
292
|
+
throw new Error('SQLite 账本缺少 note_seq 计数行(迁移未完成)。');
|
|
293
|
+
return Number(result.rows[0].next);
|
|
294
|
+
}
|
|
295
|
+
withTransaction(work) {
|
|
296
|
+
if (this.closed)
|
|
297
|
+
return Promise.reject(new Error('SQLite 账本已关闭。'));
|
|
298
|
+
// Nested transaction inside the owning scope becomes a SAVEPOINT —
|
|
299
|
+
// queueing behind the outer lock would deadlock on one connection.
|
|
300
|
+
if (this.txOwner.getStore()?.active) {
|
|
301
|
+
return this.txOwner.run({ active: true }, async () => {
|
|
302
|
+
const sp = `sp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
303
|
+
this.db.exec(`savepoint ${sp}`);
|
|
304
|
+
try {
|
|
305
|
+
const result = await work(this);
|
|
306
|
+
this.db.exec(`release savepoint ${sp}`);
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
this.db.exec(`rollback to savepoint ${sp}`);
|
|
311
|
+
this.db.exec(`release savepoint ${sp}`);
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return this.enqueue(async () => {
|
|
317
|
+
this.db.exec('begin immediate');
|
|
318
|
+
try {
|
|
319
|
+
const result = await this.txOwner.run({ active: true }, () => work(this));
|
|
320
|
+
this.db.exec('commit');
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.db.exec('rollback');
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Checkpoint WAL so a plain file copy is a consistent backup. Runs UNQUEUED
|
|
331
|
+
* (owning transactions may hold the serial lock; WAL checkpoints are safe
|
|
332
|
+
* to attempt concurrently) and reports the busy frame count — a nonzero
|
|
333
|
+
* value means frames stayed in the WAL and the caller must copy -wal too.
|
|
334
|
+
*/
|
|
335
|
+
checkpoint() {
|
|
336
|
+
return this.runUnqueued(() => {
|
|
337
|
+
const rows = this.db.prepare('pragma wal_checkpoint(truncate)').all();
|
|
338
|
+
const busy = Number(rows[0]?.busy ?? 0);
|
|
339
|
+
return { busy };
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/** Runs immediately without the serial queue; checkpoint busy-ness is
|
|
343
|
+
* reported to the caller instead of blocking on in-flight transactions. */
|
|
344
|
+
runUnqueued(job) {
|
|
345
|
+
return job();
|
|
346
|
+
}
|
|
347
|
+
close() {
|
|
348
|
+
if (this.closed)
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
this.closed = true;
|
|
351
|
+
try {
|
|
352
|
+
this.db.exec('pragma wal_checkpoint(truncate)');
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// best effort; close still flushes
|
|
356
|
+
}
|
|
357
|
+
this.db.close();
|
|
358
|
+
return Promise.resolve();
|
|
359
|
+
}
|
|
360
|
+
}
|