Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 5 additions & 54 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: npm-publish

# 按包 tag 触发 npm 发布,与 release.yml(SEA / v*)互不干扰。
# 示例: git tag @sfmc-bds/sdk@v0.1.0 && git push origin @sfmc-bds/sdk@v0.1.0
# 可发包清单唯一来源: tools/lib/npm-publish-packages.mjs

on:
push:
Expand All @@ -19,64 +20,14 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: "22.13"
# 与 ootb.yml 对齐: npm-run-all2 等要求 ≥22.22; db-server 需 ≥22.13
node-version: "22.22"
cache: npm
registry-url: https://registry.npmjs.org

- name: parse tag
- name: parse tag + verify package version
id: meta
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
PKG="${TAG%@v*}"
VER="${TAG##*@v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "pkg=$PKG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"

case "$PKG" in
@sfmc-bds/sdk) echo "workspace=@sfmc-bds/sdk" >> "$GITHUB_OUTPUT" ;;
@sfmc-bds/cli) echo "workspace=@sfmc-bds/cli" >> "$GITHUB_OUTPUT" ;;
@sfmc-bds/db-server) echo "workspace=@sfmc-bds/db-server" >> "$GITHUB_OUTPUT" ;;
@sfmc-bds/qq-bridge) echo "workspace=@sfmc-bds/qq-bridge" >> "$GITHUB_OUTPUT" ;;
@sfmc-bds/bds-tools) echo "workspace=@sfmc-bds/bds-tools" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::Unknown npm package tag: $PKG"
exit 1
;;
esac

- name: verify package version
shell: bash
env:
WORKSPACE: ${{ steps.meta.outputs.workspace }}
VER: ${{ steps.meta.outputs.ver }}
run: |
node <<'EOF'
import fs from "node:fs";

const map = {
"@sfmc-bds/sdk": "modules/sdk/@sfmc-sdk/package.json",
"@sfmc-bds/cli": "sfmc/package.json",
"@sfmc-bds/db-server": "db-server/package.json",
"@sfmc-bds/qq-bridge": "qq-bridge/package.json",
"@sfmc-bds/bds-tools": "bds-tools/package.json",
};

const ws = process.env.WORKSPACE;
const expected = process.env.VER;
const pkgPath = map[ws];
if (!pkgPath) {
console.error("No package.json mapping for", ws);
process.exit(1);
}
const ver = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
if (ver !== expected) {
console.error(`Tag version ${expected} != package.json version ${ver}`);
process.exit(1);
}
console.log(`Version OK: ${ws}@${ver}`);
EOF
run: node tools/resolve-npm-publish-tag.mjs

- run: npm ci --no-audit --no-fund

Expand Down
2 changes: 1 addition & 1 deletion db-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"test": "node --test src/*.test.js",
"test": "npm run build && node --test dist/*.test.js",
"format": "prettier --write \"src/**/*.{ts,tsx,js,json}\"",
"prepublishOnly": "npm run build"
},
Expand Down
9 changes: 1 addition & 8 deletions db-server/src/domain/economy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,10 @@ import type { EconomyAccountRow, EconomyTransactionRow } from "@sfmc-bds/sdk/con
import type { DatabaseSync } from "node:sqlite";
import type { SQLStatement } from "sql-template-strings";
import { isValidIdempotencyKey } from "../lib/idempotency.js";
import { sql } from "../lib/sql-helpers.js";
import type { TxResult } from "./transaction.js";
export type { TxResult };

/**
* 表名是信任常量,必须嵌入 SQL 文本;值走 ? 绑定。
* 不可对 sql-template-strings 插值表名(会变成 FROM ? → near "?": syntax error)。
*/
function sql(text: string, values: unknown[] = []): { sql: string; values: unknown[] } {
return { sql: text, values };
}

const TABLE_ACCOUNTS = "sfmc_economy_accounts";
const TABLE_TRANSACTIONS = "sfmc_economy_transactions";
const TABLE_IDEMPOTENCY = "sfmc_economy_idempotency";
Expand Down
32 changes: 23 additions & 9 deletions db-server/src/lib/sql-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
/**
* lib/sql-helpers.ts — sql-template-strings 缺失的工具函数
* lib/sql-helpers.ts — SQL 构造辅助
*
* 该库没有暴露 raw(...) / join(...) 这类便捷函数。抽出一个轻量封装。
* sql-template-strings 会把每一次 ${} 插值都变成 `?` 绑定值。
* 表名/列名等信任标识符必须嵌入 SQL 文本,不能走绑定 —— 否则会出现
* `FROM ?` → `near "?": syntax error`(见 economy 域修复)。
*/

import { SQL, type SQLStatement } from "sql-template-strings";

/** 参数化查询信封,与 createQuery / AnyQuery 的格式 2 对齐 */
export type BoundSql = { sql: string; values: unknown[] };

/**
* 把一段静态 SQL 文本包装成 SQLStatement,使其可以嵌入其它 SQL 模板。
* - 不携带任何占位符 / values;
* - 与 SQL\`...\` 一起使用时行为一致:`append()` 调用会把字符串合并进 strings 数组。
* 构造「标识符已嵌入、值走 ?」的查询。
* 表名等信任常量用模板字符串嵌入;用户输入只放进 values。
*
* 实现注意:sql-template-strings 的 SQL 函数对 strings array 长度 = 1 + values 长度
* 的情况友好处理 —— 我们传入 `["text"]` 单元素数组,得到一个无占位符、无参数的
* SQLStatement,正好等价于把 `text` 整段直接插入。
* @example
* sql(`SELECT * FROM ${TABLE} WHERE id = ?`, [id])
*/
export function sql(text: string, values: unknown[] = []): BoundSql {
return { sql: text, values };
}

/**
* 把一段静态 SQL 文本包装成 SQLStatement,仅可与 `.append(...)` 合用。
*
* 警告(LSP):切勿写成 `SQL\`... FROM ${raw(table)} ...\`` —— 模板插值仍会变成 `?`,
* 与直接插值表名同病。正确用法:
* SQL`SELECT * FROM `.append(raw(TABLE)).append(SQL` WHERE id = ${id}`)
* 纯字符串 `.append(TABLE)` 效果相同;优先用 {@link sql}。
*/
export function raw(text: string): SQLStatement {
return SQL([text]) as SQLStatement;
}

66 changes: 65 additions & 1 deletion db-server/src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { deepEqual, equal, throws } from "node:assert/strict";
import { deepEqual, equal, rejects, throws } from "node:assert/strict";
import test from "node:test";
import { SQL } from "sql-template-strings";
import { assertIdentifier, quoteIdentifier } from "./lib/identifiers.js";
import { parseNodeVersion } from "./lib/runtime.js";
import { raw, sql } from "./lib/sql-helpers.js";
import { DispatchError, ServiceRegistry } from "./service-registry.js";

test("parses Node versions", () => {
deepEqual(parseNodeVersion("22.5.1"), { major: 22, minor: 5, patch: 1 });
Expand All @@ -12,3 +15,64 @@ test("accepts only safe SQL identifiers", () => {
equal(quoteIdentifier("sfmc_players", "table"), '"sfmc_players"');
throws(() => assertIdentifier("users; DROP TABLE x"), /Invalid SQL/);
});

test("sql() embeds trusted identifiers and binds values", () => {
const TABLE = "sfmc_economy_accounts";
const q = sql(`SELECT * FROM ${TABLE} WHERE player_id = ?`, ["p1"]);
equal(q.sql, "SELECT * FROM sfmc_economy_accounts WHERE player_id = ?");
deepEqual(q.values, ["p1"]);
});

test("raw() only works via append — template interpolation still becomes ?", () => {
const TABLE = "sfmc_economy_accounts";
const broken = SQL`SELECT * FROM ${raw(TABLE)} WHERE id = ${"p1"}`;
equal(broken.sql, "SELECT * FROM ? WHERE id = ?");

const ok = SQL`SELECT * FROM `.append(raw(TABLE)).append(SQL` WHERE id = ${"p1"}`);
equal(ok.sql, "SELECT * FROM sfmc_economy_accounts WHERE id = ?");
deepEqual(ok.values, ["p1"]);
});

test("ServiceRegistry: provider self-call skips requires (LSP with tx.call path)", async () => {
const reg = new ServiceRegistry();
reg.registerHandler("feature-economy", "economy.stats.monthly", async () => ({ ok: true }));

const enabled = new Map([
[
"feature-economy",
{
id: "feature-economy",
version: "1.0.0",
permissions: [] as string[],
services: { provides: [{ name: "economy.stats.monthly" }], requires: [] as Array<{ name: string }> },
db: { tables: [] as unknown[] },
config: { key: "economy" },
} as unknown as import("./manifest-loader.js").ModuleManifestV2,
],
]);

const out = await reg.dispatch(enabled, "feature-economy", "economy.stats.monthly", {});
deepEqual(out, { ok: true, result: { ok: true } });
});

test("ServiceRegistry: missing service → no_such_service (not not_in_requires)", async () => {
const reg = new ServiceRegistry();
const enabled = new Map([
[
"feature-chat",
{
id: "feature-chat",
version: "1.0.0",
permissions: [] as string[],
services: { provides: [], requires: [] },
db: { tables: [] },
config: { key: "chat" },
} as unknown as import("./manifest-loader.js").ModuleManifestV2,
],
]);

await rejects(
() => reg.dispatch(enabled, "feature-chat", "economy.account.get", {}),
(e: unknown) => e instanceof DispatchError && e.code === "no_such_service"
);
});
4 changes: 0 additions & 4 deletions db-server/src/service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,6 @@ export class ServiceRegistry {
}
}

/** 查询 service 提供方 moduleId(供 tx.call 与 HTTP 共用鉴权策略) */
getProvider(name: string): string | undefined {
return this.handlers.get(name)?.moduleId;
}
}

export class DispatchError extends Error {
Expand Down
39 changes: 24 additions & 15 deletions db-server/src/tx-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type { SchemaRegistry } from "./schema-registry.js";
import type { WhereExpr } from "./where.js";
import { compile } from "./where.js";
import { log } from "./lib/log.js";
import type { ServiceRegistry } from "./service-registry.js";
import { DispatchError, type ServiceRegistry } from "./service-registry.js";
import type { ModuleManifestV2 } from "./manifest-loader.js";
import {
PermissionDeniedError,
Expand Down Expand Up @@ -124,7 +124,14 @@ export type TxError = {
ok: false;
step: number;
error: string;
code: "permission_denied" | "forbidden" | "no_such_service" | "not_in_requires" | "internal" | "no_such_table";
code:
| "permission_denied"
| "forbidden"
| "no_such_service"
| "not_in_requires"
| "domain_error"
| "internal"
| "no_such_table";
};

export interface TxRequest {
Expand Down Expand Up @@ -171,11 +178,19 @@ export class TxRunner {
const code: TxError["code"] =
err instanceof PermissionDeniedError
? "permission_denied"
: (err as { code?: string }).code === "no_such_service"
? "no_such_service"
: (err as { code?: string }).code === "not_in_requires"
? "not_in_requires"
: "internal";
: err instanceof DispatchError &&
(err.code === "no_such_service" ||
err.code === "not_in_requires" ||
err.code === "forbidden" ||
err.code === "domain_error")
? err.code
: (err as { code?: string }).code === "no_such_service"
? "no_such_service"
: (err as { code?: string }).code === "not_in_requires"
? "not_in_requires"
: (err as { code?: string }).code === "no_such_table"
? "no_such_table"
: "internal";
log.warn(`[tx ${traceId}] step=${i} failed: ${(err as Error).message}`);
return { ok: false, step: i, error: (err as Error).message, code };
}
Expand Down Expand Up @@ -382,14 +397,8 @@ export class TxRunner {
mod: ModuleManifestV2,
step: TxStepService
): Promise<TxStepResult> {
// 与 ServiceRegistry.dispatch 对齐:提供方自调用跳过 requires(DRY/LSP)
const providerId = this.deps.serviceRegistry.getProvider(step.name);
const declared = mod.services.requires.find((r) => r.name === step.name);
if (!declared && providerId !== mod.id) {
const err = new Error(`模块 ${mod.id} 未声明 service.requires ${step.name}`);
(err as { code?: string }).code = "not_in_requires";
throw err;
}
// requires / 自调用豁免的唯一权威在 ServiceRegistry.dispatch(DRY/LSP);
// 此处只补 dispatch 不做的 service:<name> 权限门(与 HTTP service-routes 对齐)。
assertModulePermission(mod.id, mod.permissions, Perm.service(step.name));
const result = await this.deps.serviceRegistry.dispatch(
this.deps.enabled,
Expand Down
9 changes: 6 additions & 3 deletions docs/dev/npm-publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ npm publish --workspace @sfmc-bds/db-server --access public

## CI 自动发布

推送符合格式的 git tag 即可触发 [`.github/workflows/npm-publish.yml`](../../.github/workflows/npm-publish.yml):
推送符合格式的 git tag 即可触发 [`.github/workflows/npm-publish.yml`](../../.github/workflows/npm-publish.yml)。
可发包清单的唯一权威来源是 [`tools/lib/npm-publish-packages.mjs`](../../tools/lib/npm-publish-packages.mjs)(workflow 解析/校验都读它,勿在 yaml 再抄一份)。

```bash
# 1. 先在对应 package.json 里 bump version
Expand All @@ -62,10 +63,12 @@ Tag 中的版本号必须与 `package.json` 的 `version` 字段一致(不含

## 模块包(可选双通道)

业务模块在 `Tanya7z/sfmc-modules` 以 `@sfmc-bds/module-<id>` 命名。可选同样用 tag 发布:
业务模块在 `Tanya7z/sfmc-modules` 以 `@sfmc-bds/module-<id>` 命名,可本地 `npm publish`。
当前 `npm-publish.yml` **只覆盖** `tools/lib/npm-publish-packages.mjs` 中的平台包;模块 tag 不会走该 CI(避免与平台包清单漂移)。

```bash
git tag @sfmc-bds/module-land@v0.1.0
# 模块包请在 sfmc-modules 仓或本地发布,勿假设 @sfmc-bds/module-*@v* 会触发本仓 workflow
npm publish --workspace @sfmc-bds/module-land --access public
```

模块 `package.json` 应声明 `"@sfmc-bds/sdk": "^0.1.0"`。
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk/@sfmc-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
}
},
"engines": {
"node": ">=22.5.0"
"node": ">=22.13.0"
},
"publishConfig": {
"access": "public",
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ export function clearConfigModuleContext(): void {
* 之前 config 漏带 query → 即便注入了 token 也会 401(LSP/DRY 违规)。
*/
function withModuleId(path: string): string {
if (!_moduleId) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`;
return HttpDB.withModuleId(path, _moduleId);
}

async function ensureLoaded(): Promise<void> {
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ export class DbError extends Error {
}

function withModuleId(path: string): string {
if (!_moduleId) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`;
return HttpDB.withModuleId(path, _moduleId);
}

async function post<T>(path: string, body: unknown): Promise<T> {
Expand Down
10 changes: 10 additions & 0 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export class HttpDB {
this.authToken = token.trim();
}

/**
* 给路径附上 ?moduleId= / &moduleId=(db/config/service 客户端共用,DRY)。
* verifyModuleAuth 只认 query 上的 moduleId。
*/
static withModuleId(path: string, moduleId: string): string {
if (!moduleId) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}moduleId=${encodeURIComponent(moduleId)}`;
}

static isAvailable(): boolean {
return this.available;
}
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/service/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ export class ServiceError extends Error {
}

function withModuleId(path: string): string {
if (!_moduleId) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`;
return HttpDB.withModuleId(path, _moduleId);
}

export const service = {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading