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
34 changes: 34 additions & 0 deletions db-server/src/lib/order-by.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* order-by.ts — 规范化 QueryOptions.orderBy(DRY + LSP)
*
* SDK 权威形态是 `{ field, dir? }`(见 @sfmc-bds/sdk/db OrderBy);
* tx-runner 早期用过 `{ col, dir? }`。两端必须互通,否则
* `ORDER BY "undefined"` / `orderBy bad column` 静默炸。
*/

export type NormalizedOrder = { col: string; dir: "asc" | "desc" };

/**
* 把 SDK `field` / 遗留 `col`、单值 / 数组 统一成 `{col,dir}[]`。
* @throws 缺列名或类型非法时
*/
export function normalizeOrderBy(orderBy: unknown): NormalizedOrder[] {
if (orderBy == null) return [];
const list = Array.isArray(orderBy) ? orderBy : [orderBy];
const out: NormalizedOrder[] = [];
for (const item of list) {
if (item == null || typeof item !== "object") {
throw new Error("[tx] orderBy 项必须是对象");
}
const rec = item as Record<string, unknown>;
const col = rec.col ?? rec.field;
if (typeof col !== "string" || !col) {
throw new Error("[tx] orderBy 缺少 field/col");
}
out.push({
col,
dir: rec.dir === "desc" ? "desc" : "asc",
});
}
return out;
}
14 changes: 14 additions & 0 deletions db-server/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,17 @@ test("jsonV2Fail: ok 方言 + extra(step) 合并(LSP/DRY)", async () => {
equal(statusCode, 400);
deepEqual(payload, { ok: false, error: "boom", code: "bad_step", step: 2 });
});

test("normalizeOrderBy: SDK field 与遗留 col / 数组互通(LSP)", async () => {
const { normalizeOrderBy } = await import("./lib/order-by.js");
deepEqual(normalizeOrderBy(undefined), []);
deepEqual(normalizeOrderBy({ field: "created_at", dir: "desc" }), [
{ col: "created_at", dir: "desc" },
]);
deepEqual(normalizeOrderBy({ col: "id" }), [{ col: "id", dir: "asc" }]);
deepEqual(normalizeOrderBy([{ field: "a" }, { col: "b", dir: "desc" }]), [
{ col: "a", dir: "asc" },
{ col: "b", dir: "desc" },
]);
throws(() => normalizeOrderBy({ dir: "asc" }), /field\/col/);
});
20 changes: 14 additions & 6 deletions db-server/src/tx-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
Perm,
assertModulePermission,
} from "./permission-gate.js";
import { normalizeOrderBy } from "./lib/order-by.js";

const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;

Expand All @@ -51,7 +52,10 @@ export interface TxStepQuery {
table: string;
opts?: {
where?: WhereExpr;
orderBy?: { col: string; dir?: "asc" | "desc" };
/** SDK 用 field;历史 col 仍接受(normalizeOrderBy) */
orderBy?:
| { field?: string; col?: string; dir?: "asc" | "desc" }
| Array<{ field?: string; col?: string; dir?: "asc" | "desc" }>;
limit?: number;
offset?: number;
};
Expand Down Expand Up @@ -261,17 +265,21 @@ export class TxRunner {
private doQuery(_mid: string, mod: ModuleManifestV2, step: TxStepQuery): TxStepResult {
this.requireTableRead(mod, step.table);
const where = compile(step.opts?.where);
const orderBy = step.opts?.orderBy;
// LSP:与 SDK QueryOptions.orderBy({field}|[]) 对齐,兼收遗留 {col}
const orders = normalizeOrderBy(step.opts?.orderBy);
const limit = typeof step.opts?.limit === "number" ? step.opts?.limit : undefined;
const offset = typeof step.opts?.offset === "number" ? step.opts?.offset : undefined;

const whereClause = where.sql;
const params = [...where.values];
let extra = "";
if (orderBy) {
if (!IDENT.test(orderBy.col)) throw new Error(`[tx] orderBy bad column "${orderBy.col}"`);
const dir = orderBy.dir === "desc" ? "DESC" : "ASC";
extra += ` ORDER BY "${orderBy.col}" ${dir}`;
if (orders.length > 0) {
const parts: string[] = [];
for (const o of orders) {
if (!IDENT.test(o.col)) throw new Error(`[tx] orderBy bad column "${o.col}"`);
parts.push(`"${o.col}" ${o.dir === "desc" ? "DESC" : "ASC"}`);
}
extra += ` ORDER BY ${parts.join(", ")}`;
}
if (typeof limit === "number") {
extra += " LIMIT " + Math.max(0, Math.floor(limit));
Expand Down
11 changes: 7 additions & 4 deletions modules/sdk/@sfmc-sdk/src/sapi/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,24 @@ import { HttpRequestMethod } from "@minecraft/server-net";

let _moduleId = "";
let _configKey = "";
let _authToken = "";
const _cache = new Map<string, unknown>();
let _loadPromise: Promise<void> | null = null;

export function setConfigModuleContext(moduleId: string, configKey: string, token: string): void {
_moduleId = moduleId;
_configKey = configKey;
_authToken = token;
_cache.clear();
_loadPromise = null;
HttpDB.setAuthToken(token);
}

export function clearConfigModuleContext(): void {
_moduleId = "";
_configKey = "";
_authToken = "";
_cache.clear();
_loadPromise = null;
HttpDB.setAuthToken("");
}

/**
Expand All @@ -53,7 +54,8 @@ async function ensureLoaded(): Promise<void> {
const res = await HttpDB.typedRequest<{ config: Record<string, unknown> }>(
HttpRequestMethod.GET,
withModuleId(`/api/sfmc/configs/${encodeURIComponent(_configKey)}`),
undefined
undefined,
{ authToken: _authToken }
);
if (res.ok && res.data) {
for (const [k, v] of Object.entries(res.data.config ?? {})) {
Expand All @@ -79,7 +81,8 @@ export const config = {
const res = await HttpDB.typedRequest<{ ok: true }>(
HttpRequestMethod.POST,
withModuleId(`/api/sfmc/configs/${encodeURIComponent(_configKey)}/set`),
{ key, value }
{ key, value },
{ authToken: _authToken }
);
if (res.ok) {
_cache.set(key, value);
Expand Down
50 changes: 29 additions & 21 deletions modules/sdk/@sfmc-sdk/src/sapi/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*
* 鉴权:
* - X-SFMC-Module-Id 通过 URL query string 传(?moduleId=...)
* - X-SFMC-Module-Token 通过 HttpDB.setAuthToken 走 Authorization: Bearer
* (设置时由 installHostBootstrap 在 db 上下文 setup 时调用)
* - Bearer token 按请求传入(HttpDB typedRequest opts),不写进程级 static
* (避免与 ConfigManager / 其它模块互相覆盖 — DIP)
*/

import { HttpDB } from "../runtime/httpdb.js";
Expand All @@ -26,17 +26,18 @@ import type {
/* ── 模块身份(由 installHostBootstrap 注入) ─────────────────────── */

let _moduleId = "";
let _authToken = "";
let _currentTxId: string | null = null;

export function setDbModuleContext(moduleId: string, token: string): void {
_moduleId = moduleId;
HttpDB.setAuthToken(token);
_authToken = token;
}

export function clearDbModuleContext(): void {
_moduleId = "";
_authToken = "";
_currentTxId = null;
HttpDB.setAuthToken("");
}

/* ── HTTP 辅助 ──────────────────────────────────────────────────── */
Expand All @@ -56,13 +57,28 @@ function withModuleId(path: string): string {
}

async function post<T>(path: string, body: unknown): Promise<T> {
const res = await HttpDB.typedRequest<T>(HttpRequestMethod.POST, withModuleId(path), body as Record<string, unknown>);
const res = await HttpDB.typedRequest<T>(
HttpRequestMethod.POST,
withModuleId(path),
body as Record<string, unknown>,
{ authToken: _authToken }
);
if (!res.ok) {
throw new DbError(res.error ?? "db_server_error", "internal", res.status);
}
return res.data as T;
}

/** 事务录制期不支持 query/get 读回:返回 []/null stub 会破坏 LSP(误导业务分支)。 */
function txReadNotSupported(op: string): never {
throw new DbError(
`db.tx 内暂不支持 ${op} 读回结果(录制后一次性提交,无交互协议);` +
`请在事务外先 db.${op},再在 tx 内做写操作`,
"tx_interactive_required",
0
);
}

/* ── 公开 API ──────────────────────────────────────────────────── */

export const db = {
Expand Down Expand Up @@ -119,7 +135,11 @@ export const db = {
});
},

/** 事务:边界在 db-server 进程。失败自动回滚,成功提交。 */
/**
* 事务:边界在 db-server 进程。失败自动回滚,成功提交。
* 录制期:写操作(insert/update/delete/audit/call)可入队;query/get 显式抛错,
* 避免返回 []/null 假数据破坏 LSP。call 可入队但返回值不可用(两阶段协议另议)。
*/
async tx<T>(fn: (tx: TxContext) => Promise<T>): Promise<T> {
if (_currentTxId) throw new DbError("嵌套事务暂不支持", "nested_tx", 0);
const steps: TxStep[] = [];
Expand All @@ -132,21 +152,8 @@ export const db = {
};

const recorder: TxContext = {
query: async <U extends Record<string, unknown> = Record<string, unknown>>(
table: string,
opts?: QueryOptions
): Promise<U[]> => {
if (opts) push({ op: "query", table, opts });
else push({ op: "query", table });
return [];
},
get: async <U extends Record<string, unknown> = Record<string, unknown>>(
table: string,
id: string | number
): Promise<U | null> => {
push({ op: "get", table, id: String(id) });
return null;
},
query: async () => txReadNotSupported("query"),
get: async () => txReadNotSupported("get"),
insert: async <U extends Record<string, unknown>>(table: string, row: U): Promise<U> => {
push({ op: "insert", table, row });
return row;
Expand All @@ -168,6 +175,7 @@ export const db = {
},
call: async <U = unknown>(name: string, input: Record<string, unknown>): Promise<U> => {
push({ op: "service", name, input });
// 录制期无服务端 result 可回放;勿依赖返回值
return undefined as unknown as U;
},
};
Expand Down
3 changes: 2 additions & 1 deletion modules/sdk/@sfmc-sdk/src/sapi/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*
* 设计:
* - db.query / get / insert / update / delete / audit / idempotent:单 RPC
* - db.tx(fn):把 fn 内的 step 录下来,一次性发 /api/sfmc/db/tx,server 端事务跑
* - db.tx(fn):把 fn 内写 step 录下来,一次性发 /api/sfmc/db/tx,server 端事务跑
* (query/get 在录制期不可用 — 避免 stub 假数据;交互读回需两阶段协议)
* - 不允许原始 SQL;只能传 WhereExpr 表达式树,平台翻译
* - 模块不能 require("fs");只能走这里
*/
Expand Down
47 changes: 32 additions & 15 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import { isSuccessfulHttpEnvelope } from "./http-envelope.js";
let baseUrl = "http://127.0.0.1:3001";
const TIMEOUT = 3;

/** 单次请求可选覆盖;模块客户端应传自己的 token,勿抢进程级默认值(DIP)。 */
export type HttpRequestAuthOpts = { authToken?: string };

export class HttpDB {
private static available = true;
private static _lastErrorLog = 0;
/** 仅 ConfigManager / DataAdapter 默认通道;模块 db/config/service 走 per-request。 */
private static authToken = "";

/** 注入 db-server 基址(如 http://127.0.0.1:4000),去掉末尾 /。 */
Expand Down Expand Up @@ -96,7 +100,8 @@ export class HttpDB {
private static async request(
method: HttpRequestMethod,
path: string,
bodyData?: Record<string, unknown>
bodyData?: Record<string, unknown>,
opts?: HttpRequestAuthOpts
): Promise<{ status: number; body: string }> {
try {
const req = new HttpRequest(`${baseUrl}${path}`);
Expand All @@ -107,7 +112,9 @@ export class HttpDB {
req.body = JSON.stringify(bodyData);
req.addHeader("Content-Type", "application/json");
}
if (this.authToken) req.addHeader("Authorization", `Bearer ${this.authToken}`);
// 请求级 token 优先,否则回落 DataAdapter 默认(ConfigManager)
const token = (opts?.authToken ?? this.authToken).trim();
if (token) req.addHeader("Authorization", `Bearer ${token}`);

const res = await http.request(req);
this.available = true;
Expand All @@ -124,17 +131,19 @@ export class HttpDB {
static async requestJSON(
method: HttpRequestMethod,
path: string,
bodyData?: Record<string, unknown>
bodyData?: Record<string, unknown>,
opts?: HttpRequestAuthOpts
): Promise<{ status: number; body: string }> {
return this.request(method, path, bodyData);
return this.request(method, path, bodyData, opts);
}

static async typedRequest<T = any>(
method: HttpRequestMethod,
path: string,
bodyData?: Record<string, unknown>
bodyData?: Record<string, unknown>,
opts?: HttpRequestAuthOpts
): Promise<{ ok: boolean; data?: T; error?: string; status: number }> {
const { status, body } = await this.request(method, path, bodyData);
const { status, body } = await this.request(method, path, bodyData, opts);
if (!body) return { ok: false, error: "network_error", status };
try {
const parsed = JSON.parse(body);
Expand All @@ -148,27 +157,35 @@ export class HttpDB {
}
}

static async get(path: string): Promise<string | null> {
const { status, body } = await this.request(HttpRequestMethod.GET, path);
static async get(path: string, opts?: HttpRequestAuthOpts): Promise<string | null> {
const { status, body } = await this.request(HttpRequestMethod.GET, path, undefined, opts);
if (status !== 200) console.info(`[HttpDB] GET ${path} → ${status}`);
return status === 200 ? body : null;
}

static async post(path: string, bodyData: Record<string, unknown>): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.POST, path, bodyData);
static async post(
path: string,
bodyData: Record<string, unknown>,
opts?: HttpRequestAuthOpts
): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.POST, path, bodyData, opts);
if (status !== 200) console.info(`[HttpDB] POST ${path} → ${status}`);
return status === 200;
}

static async put(path: string, bodyData: Record<string, unknown>): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.PUT, path, bodyData);
static async put(
path: string,
bodyData: Record<string, unknown>,
opts?: HttpRequestAuthOpts
): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.PUT, path, bodyData, opts);
if (status !== 200) console.info(`[HttpDB] PUT ${path} → ${status}`);
return status === 200;
}

static async del(path: string): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.DELETE, path);
static async del(path: string, opts?: HttpRequestAuthOpts): Promise<boolean> {
const { status } = await this.request(HttpRequestMethod.DELETE, path, undefined, opts);
if (status !== 200) console.info(`[HttpDB] DELETE ${path} → ${status}`);
return status === 200;
}
}
}
Loading
Loading