weifuwu 0.32.0 → 0.33.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/README.md +469 -393
- package/dist/base/index.d.ts +59 -0
- package/dist/base/module.d.ts +42 -0
- package/dist/base/types.d.ts +116 -0
- package/dist/cms/index.d.ts +67 -0
- package/dist/cms/module.d.ts +38 -0
- package/dist/cms/types.d.ts +96 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +2239 -86
- package/dist/kb/index.d.ts +57 -0
- package/dist/kb/module.d.ts +37 -0
- package/dist/kb/types.d.ts +83 -0
- package/dist/messager/index.d.ts +72 -0
- package/dist/messager/module.d.ts +46 -0
- package/dist/messager/types.d.ts +99 -0
- package/dist/types.d.ts +2 -2
- package/dist/user/index.d.ts +61 -0
- package/dist/user/module.d.ts +64 -0
- package/dist/user/types.d.ts +98 -0
- package/package.json +1 -1
- package/dist/middleware/auth.d.ts +0 -68
package/dist/index.js
CHANGED
|
@@ -618,21 +618,21 @@ var Router = class _Router {
|
|
|
618
618
|
}
|
|
619
619
|
// ── Private: Mount ─────────────────────────────────────────
|
|
620
620
|
_mountRouter(prefix, sub, extraMws = []) {
|
|
621
|
-
const
|
|
621
|
+
const base2 = prefix === "/" ? "" : prefix.replace(/\/$/, "");
|
|
622
622
|
const mountMw = (req, ctx, next) => {
|
|
623
|
-
ctx.mountPath = (ctx.mountPath || "") +
|
|
623
|
+
ctx.mountPath = (ctx.mountPath || "") + base2;
|
|
624
624
|
return next(req, ctx);
|
|
625
625
|
};
|
|
626
626
|
const allExtra = extraMws.length === 0 && sub.globalMws.length === 0 ? [mountMw] : [mountMw, ...extraMws, ...sub.globalMws];
|
|
627
627
|
const routes = [];
|
|
628
628
|
this._collect(sub.root, "", routes);
|
|
629
629
|
for (const { method, path, handler, middlewares } of routes) {
|
|
630
|
-
this._routeImpl(method,
|
|
630
|
+
this._routeImpl(method, base2 + path, [...allExtra, ...middlewares, handler]);
|
|
631
631
|
}
|
|
632
632
|
const wsRoutes = [];
|
|
633
633
|
this._collectWs(sub.wsRoot, "", wsRoutes);
|
|
634
634
|
for (const { path, handler, middlewares } of wsRoutes) {
|
|
635
|
-
this.ws(
|
|
635
|
+
this.ws(base2 + path, ...allExtra, ...middlewares, handler);
|
|
636
636
|
}
|
|
637
637
|
}
|
|
638
638
|
_collect(node, prefix, result) {
|
|
@@ -906,78 +906,6 @@ function cors(options) {
|
|
|
906
906
|
};
|
|
907
907
|
}
|
|
908
908
|
|
|
909
|
-
// src/middleware/auth.ts
|
|
910
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
911
|
-
function base64urlDecode(str) {
|
|
912
|
-
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
913
|
-
}
|
|
914
|
-
function sign(payload, secret) {
|
|
915
|
-
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
916
|
-
}
|
|
917
|
-
function parseCookie(cookieHeader, name) {
|
|
918
|
-
if (!cookieHeader) return null;
|
|
919
|
-
for (const c of cookieHeader.split(";")) {
|
|
920
|
-
const [key, ...rest] = c.trim().split("=");
|
|
921
|
-
if (key === name) return rest.join("=");
|
|
922
|
-
}
|
|
923
|
-
return null;
|
|
924
|
-
}
|
|
925
|
-
function auth(opts) {
|
|
926
|
-
return async (req, ctx, next) => {
|
|
927
|
-
if (opts.jwt) {
|
|
928
|
-
const cookie = opts.jwt.cookie ?? "token";
|
|
929
|
-
const token = parseCookie(req.headers.get("cookie"), cookie) ?? req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
|
930
|
-
if (token) {
|
|
931
|
-
try {
|
|
932
|
-
const [headerB64, payloadB64, sigB64] = token.split(".");
|
|
933
|
-
const expectedSig = sign(`${headerB64}.${payloadB64}`, opts.jwt.secret);
|
|
934
|
-
const sigBuf = Buffer.from(sigB64, "base64url");
|
|
935
|
-
const expectedBuf = Buffer.from(expectedSig, "base64url");
|
|
936
|
-
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
|
|
937
|
-
const payload = JSON.parse(base64urlDecode(payloadB64));
|
|
938
|
-
ctx.user = {
|
|
939
|
-
id: payload.sub ?? payload.id ?? "unknown",
|
|
940
|
-
role: payload.role,
|
|
941
|
-
tenant: payload.tenant,
|
|
942
|
-
...payload
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
} catch {
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
if (opts.session && !ctx.user) {
|
|
950
|
-
const cookie = opts.session.cookie ?? "session";
|
|
951
|
-
const raw = parseCookie(req.headers.get("cookie"), cookie);
|
|
952
|
-
if (raw) {
|
|
953
|
-
try {
|
|
954
|
-
const [dataB64, sigB64] = raw.split(".");
|
|
955
|
-
const expectedSig = sign(dataB64, opts.session.secret);
|
|
956
|
-
const sigBuf = Buffer.from(sigB64, "base64url");
|
|
957
|
-
const expectedBuf = Buffer.from(expectedSig, "base64url");
|
|
958
|
-
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
|
|
959
|
-
const data = JSON.parse(base64urlDecode(dataB64));
|
|
960
|
-
const user = await opts.session.loadUser(data);
|
|
961
|
-
if (user) ctx.user = user;
|
|
962
|
-
}
|
|
963
|
-
} catch {
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
if (opts.apiKey && !ctx.user) {
|
|
968
|
-
const header = opts.apiKey.header ?? "authorization";
|
|
969
|
-
const prefix = opts.apiKey.prefix ?? "Bearer ";
|
|
970
|
-
const raw = req.headers.get(header);
|
|
971
|
-
if (raw?.startsWith(prefix)) {
|
|
972
|
-
const key = raw.slice(prefix.length);
|
|
973
|
-
const user = await opts.apiKey.validate(key);
|
|
974
|
-
if (user) ctx.user = user;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
return next(req, ctx);
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
|
|
981
909
|
// src/middleware/static.ts
|
|
982
910
|
import { open, realpath } from "node:fs/promises";
|
|
983
911
|
import { extname, resolve, normalize, sep } from "node:path";
|
|
@@ -1192,7 +1120,7 @@ import { exec as cpExec } from "node:child_process";
|
|
|
1192
1120
|
import { promisify } from "node:util";
|
|
1193
1121
|
var runExec = promisify(cpExec);
|
|
1194
1122
|
function sandbox(opts = {}) {
|
|
1195
|
-
const
|
|
1123
|
+
const base2 = resolve2(opts.baseDir ?? "/tmp/weifuwu-sandboxes");
|
|
1196
1124
|
const defaultTimeout = opts.timeout ?? 3e4;
|
|
1197
1125
|
return async (req, ctx, next) => {
|
|
1198
1126
|
let sessionId;
|
|
@@ -1201,7 +1129,7 @@ function sandbox(opts = {}) {
|
|
|
1201
1129
|
} else {
|
|
1202
1130
|
sessionId = crypto.randomUUID();
|
|
1203
1131
|
}
|
|
1204
|
-
const workDir = join2(
|
|
1132
|
+
const workDir = join2(base2, encodeURIComponent(sessionId));
|
|
1205
1133
|
await mkdir2(workDir, { recursive: true });
|
|
1206
1134
|
function safePath(p) {
|
|
1207
1135
|
const resolved = resolve2(workDir, normalize2(p).replace(/^[/\\]+/, ""));
|
|
@@ -2429,10 +2357,10 @@ function queue(opts) {
|
|
|
2429
2357
|
const stats = () => ({ running, inflight, processed: _processed, failed: _failed, handlers: handlers.size, maxConcurrent: MAX_CONCURRENT });
|
|
2430
2358
|
const mw = ((_req, ctx, next) => {
|
|
2431
2359
|
;
|
|
2432
|
-
ctx.queue =
|
|
2360
|
+
ctx.queue = q2;
|
|
2433
2361
|
return next(_req, ctx);
|
|
2434
2362
|
});
|
|
2435
|
-
const
|
|
2363
|
+
const q2 = mw;
|
|
2436
2364
|
mw.add = function add(type, payload, opts2) {
|
|
2437
2365
|
const id = crypto3.randomUUID();
|
|
2438
2366
|
let runAt;
|
|
@@ -2551,15 +2479,15 @@ function queue(opts) {
|
|
|
2551
2479
|
});
|
|
2552
2480
|
return r;
|
|
2553
2481
|
};
|
|
2554
|
-
|
|
2482
|
+
q2.cron = function(pattern, handler) {
|
|
2555
2483
|
const id = "__cron_" + pattern.replace(/[^a-zA-Z0-9]/g, "_") + "_" + crypto3.randomUUID().slice(0, 8);
|
|
2556
|
-
|
|
2484
|
+
q2.process(id, async () => {
|
|
2557
2485
|
await handler();
|
|
2558
2486
|
});
|
|
2559
|
-
|
|
2487
|
+
q2.add(id, {}, { schedule: pattern });
|
|
2560
2488
|
return { stop: () => handlers.delete(id) };
|
|
2561
2489
|
};
|
|
2562
|
-
return
|
|
2490
|
+
return q2;
|
|
2563
2491
|
}
|
|
2564
2492
|
|
|
2565
2493
|
// src/react/index.ts
|
|
@@ -3077,17 +3005,2238 @@ function agent(opts) {
|
|
|
3077
3005
|
return next(req, ctx);
|
|
3078
3006
|
};
|
|
3079
3007
|
}
|
|
3008
|
+
|
|
3009
|
+
// src/user/module.ts
|
|
3010
|
+
import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypto";
|
|
3011
|
+
var DEFAULT_TABLE = "users";
|
|
3012
|
+
var DEFAULT_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1e3;
|
|
3013
|
+
var SALT_LENGTH = 32;
|
|
3014
|
+
var KEY_LENGTH = 64;
|
|
3015
|
+
var HASH_ALGORITHM = "sha256";
|
|
3016
|
+
function hashPassword(password) {
|
|
3017
|
+
const salt = randomBytes(SALT_LENGTH).toString("hex");
|
|
3018
|
+
const key = scryptSync(password, salt, KEY_LENGTH).toString("hex");
|
|
3019
|
+
return `scrypt:${salt}:${key}`;
|
|
3020
|
+
}
|
|
3021
|
+
async function verifyScrypt(password, stored) {
|
|
3022
|
+
const parts = stored.split(":");
|
|
3023
|
+
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
|
3024
|
+
const [, salt, key] = parts;
|
|
3025
|
+
const expected = scryptSync(password, salt, KEY_LENGTH).toString("hex");
|
|
3026
|
+
const a = Buffer.from(key, "hex");
|
|
3027
|
+
const b = Buffer.from(expected, "hex");
|
|
3028
|
+
if (a.length !== b.length) return false;
|
|
3029
|
+
return timingSafeEqual(a, b);
|
|
3030
|
+
}
|
|
3031
|
+
function base64url(data) {
|
|
3032
|
+
return Buffer.from(data).toString("base64url");
|
|
3033
|
+
}
|
|
3034
|
+
function base64urlDecode(str) {
|
|
3035
|
+
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
3036
|
+
}
|
|
3037
|
+
function hmacSign(payload, secret) {
|
|
3038
|
+
return createHmac(HASH_ALGORITHM, secret).update(payload).digest("base64url");
|
|
3039
|
+
}
|
|
3040
|
+
function tokenEncode(payload, secret) {
|
|
3041
|
+
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
3042
|
+
const body = base64url(JSON.stringify(payload));
|
|
3043
|
+
return `${header}.${body}.${hmacSign(`${header}.${body}`, secret)}`;
|
|
3044
|
+
}
|
|
3045
|
+
function tokenDecode(token, secret) {
|
|
3046
|
+
try {
|
|
3047
|
+
const parts = token.split(".");
|
|
3048
|
+
if (parts.length !== 3) return null;
|
|
3049
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
3050
|
+
const expectedSig = hmacSign(`${headerB64}.${payloadB64}`, secret);
|
|
3051
|
+
const sigBuf = Buffer.from(sigB64, "base64url");
|
|
3052
|
+
const expectedBuf = Buffer.from(expectedSig, "base64url");
|
|
3053
|
+
if (sigBuf.length !== expectedBuf.length) return null;
|
|
3054
|
+
if (!timingSafeEqual(sigBuf, expectedBuf)) return null;
|
|
3055
|
+
const payload = JSON.parse(base64urlDecode(payloadB64));
|
|
3056
|
+
if (payload.exp && payload.exp < Date.now()) return null;
|
|
3057
|
+
return payload;
|
|
3058
|
+
} catch {
|
|
3059
|
+
return null;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
function parseCookie(cookieHeader, name) {
|
|
3063
|
+
if (!cookieHeader) return null;
|
|
3064
|
+
for (const c of cookieHeader.split(";")) {
|
|
3065
|
+
const [key, ...rest] = c.trim().split("=");
|
|
3066
|
+
if (key === name) return rest.join("=");
|
|
3067
|
+
}
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
function extractToken(req) {
|
|
3071
|
+
return req.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? parseCookie(req.headers.get("cookie"), "token") ?? null;
|
|
3072
|
+
}
|
|
3073
|
+
function toUserRecord(row) {
|
|
3074
|
+
return {
|
|
3075
|
+
id: row.id,
|
|
3076
|
+
email: row.email,
|
|
3077
|
+
name: row.name,
|
|
3078
|
+
role: row.role,
|
|
3079
|
+
avatar: row.avatar,
|
|
3080
|
+
is_active: row.is_active,
|
|
3081
|
+
created_at: row.created_at,
|
|
3082
|
+
updated_at: row.updated_at,
|
|
3083
|
+
last_login_at: row.last_login_at
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
function getSqlFromCtx(ctx) {
|
|
3087
|
+
const sql = ctx.sql;
|
|
3088
|
+
if (!sql) {
|
|
3089
|
+
throw new Error(
|
|
3090
|
+
"user() requires postgres() middleware to be registered first.\nMake sure app.use(postgres()) is called before app.use(user())"
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
return sql;
|
|
3094
|
+
}
|
|
3095
|
+
var UserModule = class {
|
|
3096
|
+
table;
|
|
3097
|
+
secret;
|
|
3098
|
+
tokenExpiry;
|
|
3099
|
+
migrated = false;
|
|
3100
|
+
constructor(opts) {
|
|
3101
|
+
this.table = opts?.table ?? DEFAULT_TABLE;
|
|
3102
|
+
this.secret = opts?.secret ?? process.env.JWT_SECRET ?? "change-me-in-production";
|
|
3103
|
+
this.tokenExpiry = opts?.tokenExpiry ?? DEFAULT_TOKEN_EXPIRY;
|
|
3104
|
+
}
|
|
3105
|
+
// ── Migration ─────────────────────────────────────────────
|
|
3106
|
+
async migrate(sql) {
|
|
3107
|
+
if (this.migrated) return;
|
|
3108
|
+
await sql.unsafe(`
|
|
3109
|
+
CREATE TABLE IF NOT EXISTS "${this.table}" (
|
|
3110
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3111
|
+
email TEXT NOT NULL UNIQUE,
|
|
3112
|
+
name TEXT NOT NULL,
|
|
3113
|
+
password TEXT NOT NULL,
|
|
3114
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
3115
|
+
avatar TEXT,
|
|
3116
|
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
3117
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3118
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3119
|
+
last_login_at TIMESTAMPTZ
|
|
3120
|
+
)
|
|
3121
|
+
`);
|
|
3122
|
+
await sql.unsafe(`CREATE INDEX IF NOT EXISTS "${this.table}_email_idx" ON "${this.table}" (email)`);
|
|
3123
|
+
await sql.unsafe(`CREATE INDEX IF NOT EXISTS "${this.table}_role_idx" ON "${this.table}" (role)`);
|
|
3124
|
+
this.migrated = true;
|
|
3125
|
+
}
|
|
3126
|
+
// ── Per-request bound API ─────────────────────────────────
|
|
3127
|
+
/**
|
|
3128
|
+
* Create a per-request `UserModuleAPI` bound to the request's SQL context.
|
|
3129
|
+
* Called internally by the middleware. Thread-safe because each request
|
|
3130
|
+
* gets its own closure with a captured `ctx`.
|
|
3131
|
+
*/
|
|
3132
|
+
bind(ctx) {
|
|
3133
|
+
const self = this;
|
|
3134
|
+
const sql = getSqlFromCtx(ctx);
|
|
3135
|
+
if (!this.migrated) {
|
|
3136
|
+
this.migrate(sql).catch(() => {
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
return {
|
|
3140
|
+
createUser(input) {
|
|
3141
|
+
return self._createUser(input, sql);
|
|
3142
|
+
},
|
|
3143
|
+
register(input) {
|
|
3144
|
+
return self._register(input, sql);
|
|
3145
|
+
},
|
|
3146
|
+
login(email, password) {
|
|
3147
|
+
return self._login(email, password, sql);
|
|
3148
|
+
},
|
|
3149
|
+
getUserById(id) {
|
|
3150
|
+
return self._getUserById(id, sql);
|
|
3151
|
+
},
|
|
3152
|
+
getUserByEmail(email) {
|
|
3153
|
+
return self._getUserByEmail(email, sql);
|
|
3154
|
+
},
|
|
3155
|
+
updateUser(id, input) {
|
|
3156
|
+
return self._updateUser(id, input, sql);
|
|
3157
|
+
},
|
|
3158
|
+
deleteUser(id) {
|
|
3159
|
+
return self._deleteUser(id, sql);
|
|
3160
|
+
},
|
|
3161
|
+
listUsers(includeInactive = false) {
|
|
3162
|
+
return self._listUsers(sql, includeInactive);
|
|
3163
|
+
},
|
|
3164
|
+
changePassword(id, currentPassword, newPassword) {
|
|
3165
|
+
return self._changePassword(id, currentPassword, newPassword, sql);
|
|
3166
|
+
},
|
|
3167
|
+
verifyPassword(password, hash) {
|
|
3168
|
+
return verifyScrypt(password, hash);
|
|
3169
|
+
},
|
|
3170
|
+
generateToken(user2) {
|
|
3171
|
+
return Promise.resolve(self._generateToken(user2));
|
|
3172
|
+
},
|
|
3173
|
+
verifyToken(token) {
|
|
3174
|
+
return Promise.resolve(self._verifyToken(token));
|
|
3175
|
+
},
|
|
3176
|
+
refreshToken(token) {
|
|
3177
|
+
return Promise.resolve(self._refreshToken(token));
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
// ── Internal CRUD (take sql explicitly) ────────────────────
|
|
3182
|
+
async ensureMigrated(sql) {
|
|
3183
|
+
if (!this.migrated) await this.migrate(sql);
|
|
3184
|
+
}
|
|
3185
|
+
async _register(input, sql) {
|
|
3186
|
+
const user2 = await this._createUser(input, sql);
|
|
3187
|
+
const token = this._generateToken(user2);
|
|
3188
|
+
return { user: user2, token };
|
|
3189
|
+
}
|
|
3190
|
+
async _createUser(input, sql) {
|
|
3191
|
+
await this.ensureMigrated(sql);
|
|
3192
|
+
const hash = hashPassword(input.password);
|
|
3193
|
+
const role = input.role ?? "user";
|
|
3194
|
+
const isActive = input.is_active ?? true;
|
|
3195
|
+
const [row] = await sql.unsafe(
|
|
3196
|
+
`INSERT INTO "${this.table}" (email, name, password, role, avatar, is_active)
|
|
3197
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
3198
|
+
RETURNING *`,
|
|
3199
|
+
[input.email, input.name, hash, role, input.avatar ?? null, isActive]
|
|
3200
|
+
);
|
|
3201
|
+
if (!row) throw new Error("Failed to create user");
|
|
3202
|
+
return toUserRecord(row);
|
|
3203
|
+
}
|
|
3204
|
+
async _getUserById(id, sql) {
|
|
3205
|
+
await this.ensureMigrated(sql);
|
|
3206
|
+
const [row] = await sql.unsafe(
|
|
3207
|
+
`SELECT * FROM "${this.table}" WHERE id = $1`,
|
|
3208
|
+
[id]
|
|
3209
|
+
);
|
|
3210
|
+
return row ? toUserRecord(row) : null;
|
|
3211
|
+
}
|
|
3212
|
+
async _getUserByEmail(email, sql) {
|
|
3213
|
+
await this.ensureMigrated(sql);
|
|
3214
|
+
const [row] = await sql.unsafe(
|
|
3215
|
+
`SELECT * FROM "${this.table}" WHERE email = $1`,
|
|
3216
|
+
[email]
|
|
3217
|
+
);
|
|
3218
|
+
return row ? toUserRecord(row) : null;
|
|
3219
|
+
}
|
|
3220
|
+
async _updateUser(id, input, sql) {
|
|
3221
|
+
await this.ensureMigrated(sql);
|
|
3222
|
+
const sets = [];
|
|
3223
|
+
const values = [];
|
|
3224
|
+
let idx = 1;
|
|
3225
|
+
if (input.name !== void 0) {
|
|
3226
|
+
sets.push(`name = $${idx++}`);
|
|
3227
|
+
values.push(input.name);
|
|
3228
|
+
}
|
|
3229
|
+
if (input.email !== void 0) {
|
|
3230
|
+
sets.push(`email = $${idx++}`);
|
|
3231
|
+
values.push(input.email);
|
|
3232
|
+
}
|
|
3233
|
+
if (input.role !== void 0) {
|
|
3234
|
+
sets.push(`role = $${idx++}`);
|
|
3235
|
+
values.push(input.role);
|
|
3236
|
+
}
|
|
3237
|
+
if (input.avatar !== void 0) {
|
|
3238
|
+
sets.push(`avatar = $${idx++}`);
|
|
3239
|
+
values.push(input.avatar);
|
|
3240
|
+
}
|
|
3241
|
+
if (input.is_active !== void 0) {
|
|
3242
|
+
sets.push(`is_active = $${idx++}`);
|
|
3243
|
+
values.push(input.is_active);
|
|
3244
|
+
}
|
|
3245
|
+
if (input.password !== void 0) {
|
|
3246
|
+
sets.push(`password = $${idx++}`);
|
|
3247
|
+
values.push(hashPassword(input.password));
|
|
3248
|
+
}
|
|
3249
|
+
if (sets.length === 0) return this._getUserById(id, sql);
|
|
3250
|
+
sets.push("updated_at = NOW()");
|
|
3251
|
+
values.push(id);
|
|
3252
|
+
const [row] = await sql.unsafe(
|
|
3253
|
+
`UPDATE "${this.table}" SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
|
3254
|
+
values
|
|
3255
|
+
);
|
|
3256
|
+
return row ? toUserRecord(row) : null;
|
|
3257
|
+
}
|
|
3258
|
+
async _deleteUser(id, sql) {
|
|
3259
|
+
await this.ensureMigrated(sql);
|
|
3260
|
+
const [row] = await sql.unsafe(
|
|
3261
|
+
`UPDATE "${this.table}" SET is_active = FALSE, updated_at = NOW() WHERE id = $1 RETURNING id`,
|
|
3262
|
+
[id]
|
|
3263
|
+
);
|
|
3264
|
+
return !!row;
|
|
3265
|
+
}
|
|
3266
|
+
async _listUsers(sql, includeInactive) {
|
|
3267
|
+
await this.ensureMigrated(sql);
|
|
3268
|
+
const rows = includeInactive ? await sql.unsafe(`SELECT * FROM "${this.table}" ORDER BY created_at DESC`) : await sql.unsafe(`SELECT * FROM "${this.table}" WHERE is_active = TRUE ORDER BY created_at DESC`);
|
|
3269
|
+
return rows.map(toUserRecord);
|
|
3270
|
+
}
|
|
3271
|
+
async _login(email, password, sql) {
|
|
3272
|
+
await this.ensureMigrated(sql);
|
|
3273
|
+
const [row] = await sql.unsafe(
|
|
3274
|
+
`SELECT * FROM "${this.table}" WHERE email = $1`,
|
|
3275
|
+
[email]
|
|
3276
|
+
);
|
|
3277
|
+
if (!row) return null;
|
|
3278
|
+
if (!await verifyScrypt(password, row.password)) return null;
|
|
3279
|
+
const user2 = toUserRecord(row);
|
|
3280
|
+
const token = this._generateToken(user2);
|
|
3281
|
+
await sql.unsafe(`UPDATE "${this.table}" SET last_login_at = NOW() WHERE id = $1`, [user2.id]);
|
|
3282
|
+
return { user: user2, token };
|
|
3283
|
+
}
|
|
3284
|
+
async _changePassword(id, currentPassword, newPassword, sql) {
|
|
3285
|
+
await this.ensureMigrated(sql);
|
|
3286
|
+
const [row] = await sql.unsafe(
|
|
3287
|
+
`SELECT password FROM "${this.table}" WHERE id = $1`,
|
|
3288
|
+
[id]
|
|
3289
|
+
);
|
|
3290
|
+
if (!row) return false;
|
|
3291
|
+
if (!await verifyScrypt(currentPassword, row.password)) return false;
|
|
3292
|
+
const hash = hashPassword(newPassword);
|
|
3293
|
+
await sql.unsafe(
|
|
3294
|
+
`UPDATE "${this.table}" SET password = $1, updated_at = NOW() WHERE id = $2`,
|
|
3295
|
+
[hash, id]
|
|
3296
|
+
);
|
|
3297
|
+
return true;
|
|
3298
|
+
}
|
|
3299
|
+
// ── Token helpers (stateless, no sql needed) ────────────────
|
|
3300
|
+
_generateToken(user2) {
|
|
3301
|
+
const now = Date.now();
|
|
3302
|
+
return tokenEncode(
|
|
3303
|
+
{ sub: user2.id, email: user2.email, role: user2.role, iat: now, exp: now + this.tokenExpiry },
|
|
3304
|
+
this.secret
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
_verifyToken(token) {
|
|
3308
|
+
return tokenDecode(token, this.secret);
|
|
3309
|
+
}
|
|
3310
|
+
_refreshToken(token) {
|
|
3311
|
+
const payload = tokenDecode(token, this.secret);
|
|
3312
|
+
if (!payload) return null;
|
|
3313
|
+
const now = Date.now();
|
|
3314
|
+
return tokenEncode(
|
|
3315
|
+
{ sub: payload.sub, email: payload.email, role: payload.role, iat: now, exp: now + this.tokenExpiry },
|
|
3316
|
+
this.secret
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3319
|
+
// ── Middleware ──────────────────────────────────────────────
|
|
3320
|
+
/**
|
|
3321
|
+
* Middleware entry point. Injects `ctx.userModule` (per-request bound API)
|
|
3322
|
+
* and resolves `ctx.user` from the Authorization header or `token` cookie.
|
|
3323
|
+
*/
|
|
3324
|
+
async middleware(req, ctx, next) {
|
|
3325
|
+
ctx.userModule = this.bind(ctx);
|
|
3326
|
+
const token = extractToken(req);
|
|
3327
|
+
if (token && !ctx.user) {
|
|
3328
|
+
try {
|
|
3329
|
+
const payload = tokenDecode(token, this.secret);
|
|
3330
|
+
if (payload) {
|
|
3331
|
+
const sql = getSqlFromCtx(ctx);
|
|
3332
|
+
const [row] = await sql.unsafe(
|
|
3333
|
+
`SELECT * FROM "${this.table}" WHERE id = $1`,
|
|
3334
|
+
[payload.sub]
|
|
3335
|
+
);
|
|
3336
|
+
if (row) {
|
|
3337
|
+
ctx.user = toUserRecord(row);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
} catch {
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return next(req, ctx);
|
|
3344
|
+
}
|
|
3345
|
+
};
|
|
3346
|
+
|
|
3347
|
+
// src/user/types.ts
|
|
3348
|
+
function requireRole(...roles) {
|
|
3349
|
+
return (req, ctx, next) => {
|
|
3350
|
+
if (!ctx.user) {
|
|
3351
|
+
return new Response("Unauthorized", { status: 401 });
|
|
3352
|
+
}
|
|
3353
|
+
const userRole = ctx.user.role;
|
|
3354
|
+
if (!userRole || !roles.includes(userRole)) {
|
|
3355
|
+
return new Response("Forbidden", { status: 403 });
|
|
3356
|
+
}
|
|
3357
|
+
return next(req, ctx);
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
// src/user/index.ts
|
|
3362
|
+
function user(opts) {
|
|
3363
|
+
const module = new UserModule(opts);
|
|
3364
|
+
const mw = ((req, ctx, next) => {
|
|
3365
|
+
return module.middleware(req, ctx, next);
|
|
3366
|
+
});
|
|
3367
|
+
mw.__meta = { injects: ["userModule", "user"], depends: ["sql"] };
|
|
3368
|
+
return mw;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// src/messager/module.ts
|
|
3372
|
+
var DEFAULT_LIMIT = 50;
|
|
3373
|
+
var MAX_LIMIT = 200;
|
|
3374
|
+
var EDIT_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
3375
|
+
function toConversation(row) {
|
|
3376
|
+
return {
|
|
3377
|
+
id: row.id,
|
|
3378
|
+
type: row.type,
|
|
3379
|
+
title: row.title,
|
|
3380
|
+
created_by: row.created_by,
|
|
3381
|
+
created_at: row.created_at,
|
|
3382
|
+
updated_at: row.updated_at,
|
|
3383
|
+
participant_count: row.participant_count,
|
|
3384
|
+
last_message: row.last_message ? row.last_message : void 0,
|
|
3385
|
+
unread_count: row.unread_count
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
function toMessage(row) {
|
|
3389
|
+
return {
|
|
3390
|
+
id: row.id,
|
|
3391
|
+
conversation_id: row.conversation_id,
|
|
3392
|
+
sender_id: row.sender_id,
|
|
3393
|
+
sender_name: row.sender_name,
|
|
3394
|
+
body: row.body,
|
|
3395
|
+
created_at: row.created_at,
|
|
3396
|
+
updated_at: row.updated_at,
|
|
3397
|
+
edited: row.edited,
|
|
3398
|
+
deleted_at: row.deleted_at
|
|
3399
|
+
};
|
|
3400
|
+
}
|
|
3401
|
+
function getSql(ctx) {
|
|
3402
|
+
const sql = ctx.sql;
|
|
3403
|
+
if (!sql) {
|
|
3404
|
+
throw new Error(
|
|
3405
|
+
"messager() requires postgres() middleware to be registered first."
|
|
3406
|
+
);
|
|
3407
|
+
}
|
|
3408
|
+
return sql;
|
|
3409
|
+
}
|
|
3410
|
+
var Messager = class {
|
|
3411
|
+
migrated = false;
|
|
3412
|
+
prefix;
|
|
3413
|
+
usersTable;
|
|
3414
|
+
constructor(opts) {
|
|
3415
|
+
this.prefix = opts?.tablePrefix ?? "";
|
|
3416
|
+
this.usersTable = opts?.usersTable ?? "users";
|
|
3417
|
+
}
|
|
3418
|
+
// ── Table names ────────────────────────────────────────────
|
|
3419
|
+
get tConversations() {
|
|
3420
|
+
return `"${this.prefix}conversations"`;
|
|
3421
|
+
}
|
|
3422
|
+
get tParticipants() {
|
|
3423
|
+
return `"${this.prefix}participants"`;
|
|
3424
|
+
}
|
|
3425
|
+
get tMessages() {
|
|
3426
|
+
return `"${this.prefix}messages"`;
|
|
3427
|
+
}
|
|
3428
|
+
get tUsers() {
|
|
3429
|
+
return `"${this.usersTable}"`;
|
|
3430
|
+
}
|
|
3431
|
+
q(name) {
|
|
3432
|
+
return `"${this.prefix}${name}"`;
|
|
3433
|
+
}
|
|
3434
|
+
// ── Migration ──────────────────────────────────────────────
|
|
3435
|
+
async migrate(sql) {
|
|
3436
|
+
if (this.migrated) return;
|
|
3437
|
+
await sql.unsafe(`
|
|
3438
|
+
CREATE TABLE IF NOT EXISTS ${this.q("conversations")} (
|
|
3439
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3440
|
+
type TEXT NOT NULL CHECK (type IN ('direct', 'group')),
|
|
3441
|
+
title TEXT,
|
|
3442
|
+
created_by UUID NOT NULL,
|
|
3443
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3444
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3445
|
+
)
|
|
3446
|
+
`);
|
|
3447
|
+
await sql.unsafe(`
|
|
3448
|
+
CREATE INDEX IF NOT EXISTS ${this.q("conversations_updated_idx")}
|
|
3449
|
+
ON ${this.q("conversations")} (updated_at DESC)
|
|
3450
|
+
`);
|
|
3451
|
+
await sql.unsafe(`
|
|
3452
|
+
CREATE TABLE IF NOT EXISTS ${this.q("participants")} (
|
|
3453
|
+
conversation_id UUID NOT NULL REFERENCES ${this.q("conversations")}(id) ON DELETE CASCADE,
|
|
3454
|
+
user_id UUID NOT NULL,
|
|
3455
|
+
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
|
|
3456
|
+
last_read_at TIMESTAMPTZ,
|
|
3457
|
+
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3458
|
+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
3459
|
+
PRIMARY KEY (conversation_id, user_id)
|
|
3460
|
+
)
|
|
3461
|
+
`);
|
|
3462
|
+
await sql.unsafe(`
|
|
3463
|
+
CREATE INDEX IF NOT EXISTS ${this.q("participants_user_idx")}
|
|
3464
|
+
ON ${this.q("participants")} (user_id, is_active)
|
|
3465
|
+
`);
|
|
3466
|
+
await sql.unsafe(`
|
|
3467
|
+
CREATE TABLE IF NOT EXISTS ${this.q("messages")} (
|
|
3468
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3469
|
+
conversation_id UUID NOT NULL REFERENCES ${this.q("conversations")}(id) ON DELETE CASCADE,
|
|
3470
|
+
sender_id UUID NOT NULL,
|
|
3471
|
+
body TEXT NOT NULL,
|
|
3472
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3473
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3474
|
+
edited BOOLEAN NOT NULL DEFAULT FALSE,
|
|
3475
|
+
deleted_at TIMESTAMPTZ
|
|
3476
|
+
)
|
|
3477
|
+
`);
|
|
3478
|
+
await sql.unsafe(`
|
|
3479
|
+
CREATE INDEX IF NOT EXISTS ${this.q("messages_conv_idx")}
|
|
3480
|
+
ON ${this.q("messages")} (conversation_id, created_at DESC)
|
|
3481
|
+
`);
|
|
3482
|
+
this.migrated = true;
|
|
3483
|
+
}
|
|
3484
|
+
async ensureMigrated(sql) {
|
|
3485
|
+
if (!this.migrated) await this.migrate(sql);
|
|
3486
|
+
}
|
|
3487
|
+
// ── Per-request bound API ──────────────────────────────────
|
|
3488
|
+
bind(ctx) {
|
|
3489
|
+
const self = this;
|
|
3490
|
+
const sql = getSql(ctx);
|
|
3491
|
+
if (!this.migrated) {
|
|
3492
|
+
this.migrate(sql).catch(() => {
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
function currentUserId3() {
|
|
3496
|
+
const u = ctx.user;
|
|
3497
|
+
if (!u?.id) throw new Error("messager() requires user() middleware \u2014 ctx.user is missing");
|
|
3498
|
+
return u.id;
|
|
3499
|
+
}
|
|
3500
|
+
function broadcast(conversationId, event, data) {
|
|
3501
|
+
try {
|
|
3502
|
+
const ws = ctx.ws;
|
|
3503
|
+
if (ws && typeof ws.sendRoom === "function") {
|
|
3504
|
+
;
|
|
3505
|
+
ws.sendRoom(`conversation:${conversationId}`, {
|
|
3506
|
+
type: event,
|
|
3507
|
+
...data
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
} catch {
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
return {
|
|
3514
|
+
// ── Conversations ──────────────────────────────────────
|
|
3515
|
+
async createDirectConversation(otherUserId) {
|
|
3516
|
+
const me = currentUserId3();
|
|
3517
|
+
const [, otherId] = me < otherUserId ? [me, otherUserId] : [otherUserId, me];
|
|
3518
|
+
await self.ensureMigrated(sql);
|
|
3519
|
+
const existing = await sql.unsafe(`
|
|
3520
|
+
SELECT c.id FROM ${self.q("conversations")} c
|
|
3521
|
+
WHERE c.type = 'direct'
|
|
3522
|
+
AND EXISTS (SELECT 1 FROM ${self.q("participants")} p1
|
|
3523
|
+
WHERE p1.conversation_id = c.id AND p1.user_id = $1 AND p1.is_active = TRUE)
|
|
3524
|
+
AND EXISTS (SELECT 1 FROM ${self.q("participants")} p2
|
|
3525
|
+
WHERE p2.conversation_id = c.id AND p2.user_id = $2 AND p2.is_active = TRUE)
|
|
3526
|
+
ORDER BY c.created_at ASC LIMIT 1
|
|
3527
|
+
`, [me, otherId]);
|
|
3528
|
+
if (existing.length > 0) {
|
|
3529
|
+
return self._getConversationById(existing[0].id, sql, me);
|
|
3530
|
+
}
|
|
3531
|
+
const [conv] = await sql.unsafe(`
|
|
3532
|
+
INSERT INTO ${self.q("conversations")} (type, created_by) VALUES ('direct', $1) RETURNING *
|
|
3533
|
+
`, [me]);
|
|
3534
|
+
await sql.unsafe(`
|
|
3535
|
+
INSERT INTO ${self.q("participants")} (conversation_id, user_id, role)
|
|
3536
|
+
VALUES ($1, $2, 'member'), ($1, $3, 'member')
|
|
3537
|
+
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
|
3538
|
+
`, [conv.id, me, otherId]);
|
|
3539
|
+
return toConversation(conv);
|
|
3540
|
+
},
|
|
3541
|
+
async createGroupConversation(title, userIds) {
|
|
3542
|
+
const me = currentUserId3();
|
|
3543
|
+
const all = [me, ...userIds.filter((id) => id !== me)];
|
|
3544
|
+
await self.ensureMigrated(sql);
|
|
3545
|
+
const [conv] = await sql.unsafe(`
|
|
3546
|
+
INSERT INTO ${self.q("conversations")} (type, title, created_by)
|
|
3547
|
+
VALUES ('group', $1, $2) RETURNING *
|
|
3548
|
+
`, [title, me]);
|
|
3549
|
+
const values = all.map(
|
|
3550
|
+
(uid, i) => `($${i * 2 + 1}, $${i * 2 + 2}, ${uid === me ? "'admin'" : "'member'"})`
|
|
3551
|
+
).join(", ");
|
|
3552
|
+
await sql.unsafe(`
|
|
3553
|
+
INSERT INTO ${self.q("participants")} (conversation_id, user_id, role)
|
|
3554
|
+
VALUES ${values}
|
|
3555
|
+
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
|
3556
|
+
`, all.flatMap((uid) => [conv.id, uid]));
|
|
3557
|
+
return toConversation(conv);
|
|
3558
|
+
},
|
|
3559
|
+
async getConversation(conversationId) {
|
|
3560
|
+
const me = currentUserId3();
|
|
3561
|
+
await self.ensureMigrated(sql);
|
|
3562
|
+
return self._getConversationById(conversationId, sql, me);
|
|
3563
|
+
},
|
|
3564
|
+
async getConversations() {
|
|
3565
|
+
const me = currentUserId3();
|
|
3566
|
+
await self.ensureMigrated(sql);
|
|
3567
|
+
const rows = await sql.unsafe(`
|
|
3568
|
+
SELECT
|
|
3569
|
+
c.*,
|
|
3570
|
+
(SELECT COUNT(*) FROM ${self.q("participants")} p
|
|
3571
|
+
WHERE p.conversation_id = c.id AND p.is_active = TRUE) AS participant_count,
|
|
3572
|
+
(SELECT row_to_json(msg.*) FROM (
|
|
3573
|
+
SELECT m.id, m.body, m.sender_id, m.created_at,
|
|
3574
|
+
(SELECT u.name FROM ${self.tUsers} u WHERE u.id = m.sender_id) AS sender_name
|
|
3575
|
+
FROM ${self.q("messages")} m
|
|
3576
|
+
WHERE m.conversation_id = c.id AND m.deleted_at IS NULL
|
|
3577
|
+
ORDER BY m.created_at DESC LIMIT 1
|
|
3578
|
+
) msg) AS last_message,
|
|
3579
|
+
(SELECT COUNT(*) FROM ${self.q("messages")} m
|
|
3580
|
+
WHERE m.conversation_id = c.id
|
|
3581
|
+
AND m.deleted_at IS NULL
|
|
3582
|
+
AND m.created_at > COALESCE(p.last_read_at, '1970-01-01'::timestamptz)
|
|
3583
|
+
AND m.sender_id != $1) AS unread_count
|
|
3584
|
+
FROM ${self.q("conversations")} c
|
|
3585
|
+
JOIN ${self.q("participants")} p ON p.conversation_id = c.id AND p.user_id = $1 AND p.is_active = TRUE
|
|
3586
|
+
ORDER BY c.updated_at DESC
|
|
3587
|
+
`, [me]);
|
|
3588
|
+
return rows.map(toConversation);
|
|
3589
|
+
},
|
|
3590
|
+
async addParticipants(conversationId, userIds) {
|
|
3591
|
+
const me = currentUserId3();
|
|
3592
|
+
await self.ensureMigrated(sql);
|
|
3593
|
+
const [caller] = await sql.unsafe(`
|
|
3594
|
+
SELECT role FROM ${self.q("participants")}
|
|
3595
|
+
WHERE conversation_id = $1 AND user_id = $2 AND is_active = TRUE
|
|
3596
|
+
`, [conversationId, me]);
|
|
3597
|
+
if (!caller) throw new Error("Not a participant");
|
|
3598
|
+
if (caller.role !== "admin") throw new Error("Only admins can add participants");
|
|
3599
|
+
const values = userIds.map(
|
|
3600
|
+
(uid, i) => `($1, $${i + 2}, 'member')`
|
|
3601
|
+
).join(", ");
|
|
3602
|
+
await sql.unsafe(`
|
|
3603
|
+
INSERT INTO ${self.q("participants")} (conversation_id, user_id, role)
|
|
3604
|
+
VALUES ${values}
|
|
3605
|
+
ON CONFLICT (conversation_id, user_id) DO UPDATE SET is_active = TRUE
|
|
3606
|
+
`, [conversationId, ...userIds]);
|
|
3607
|
+
await sql.unsafe(`UPDATE ${self.q("conversations")} SET updated_at = NOW() WHERE id = $1`, [conversationId]);
|
|
3608
|
+
},
|
|
3609
|
+
async removeParticipant(conversationId, targetUserId) {
|
|
3610
|
+
const me = currentUserId3();
|
|
3611
|
+
await self.ensureMigrated(sql);
|
|
3612
|
+
const targetId = targetUserId ?? me;
|
|
3613
|
+
if (targetId !== me) {
|
|
3614
|
+
const [caller] = await sql.unsafe(`
|
|
3615
|
+
SELECT role FROM ${self.q("participants")}
|
|
3616
|
+
WHERE conversation_id = $1 AND user_id = $2 AND is_active = TRUE
|
|
3617
|
+
`, [conversationId, me]);
|
|
3618
|
+
if (!caller || caller.role !== "admin") throw new Error("Only admins can remove participants");
|
|
3619
|
+
}
|
|
3620
|
+
const [row] = await sql.unsafe(`
|
|
3621
|
+
UPDATE ${self.q("participants")} SET is_active = FALSE
|
|
3622
|
+
WHERE conversation_id = $1 AND user_id = $2 AND is_active = TRUE
|
|
3623
|
+
RETURNING user_id
|
|
3624
|
+
`, [conversationId, targetId]);
|
|
3625
|
+
if (row) {
|
|
3626
|
+
await sql.unsafe(`UPDATE ${self.q("conversations")} SET updated_at = NOW() WHERE id = $1`, [conversationId]);
|
|
3627
|
+
}
|
|
3628
|
+
return !!row;
|
|
3629
|
+
},
|
|
3630
|
+
// ── Messages ───────────────────────────────────────────
|
|
3631
|
+
async sendMessage(conversationId, body) {
|
|
3632
|
+
const me = currentUserId3();
|
|
3633
|
+
if (!body.trim()) throw new Error("Message body cannot be empty");
|
|
3634
|
+
await self.ensureMigrated(sql);
|
|
3635
|
+
const [part] = await sql.unsafe(`
|
|
3636
|
+
SELECT user_id FROM ${self.q("participants")}
|
|
3637
|
+
WHERE conversation_id = $1 AND user_id = $2 AND is_active = TRUE
|
|
3638
|
+
`, [conversationId, me]);
|
|
3639
|
+
if (!part) throw new Error("Not a participant in this conversation");
|
|
3640
|
+
const [msg] = await sql.unsafe(`
|
|
3641
|
+
INSERT INTO ${self.q("messages")} (conversation_id, sender_id, body)
|
|
3642
|
+
VALUES ($1, $2, $3) RETURNING *
|
|
3643
|
+
`, [conversationId, me, body.trim()]);
|
|
3644
|
+
await sql.unsafe(`UPDATE ${self.q("conversations")} SET updated_at = NOW() WHERE id = $1`, [conversationId]);
|
|
3645
|
+
const message = toMessage(msg);
|
|
3646
|
+
message.sender_name = ctx.user ? ctx.user.name : void 0;
|
|
3647
|
+
broadcast(conversationId, "new_message", { message });
|
|
3648
|
+
return message;
|
|
3649
|
+
},
|
|
3650
|
+
async getMessages(conversationId, opts) {
|
|
3651
|
+
const me = currentUserId3();
|
|
3652
|
+
await self.ensureMigrated(sql);
|
|
3653
|
+
const [part] = await sql.unsafe(`
|
|
3654
|
+
SELECT user_id FROM ${self.q("participants")}
|
|
3655
|
+
WHERE conversation_id = $1 AND user_id = $2 AND is_active = TRUE
|
|
3656
|
+
`, [conversationId, me]);
|
|
3657
|
+
if (!part) return [];
|
|
3658
|
+
const limit = Math.min(opts?.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
3659
|
+
let rows;
|
|
3660
|
+
if (opts?.before) {
|
|
3661
|
+
const [cursor] = await sql.unsafe(`
|
|
3662
|
+
SELECT created_at FROM ${self.q("messages")} WHERE id = $1
|
|
3663
|
+
`, [opts.before]);
|
|
3664
|
+
if (cursor) {
|
|
3665
|
+
rows = await sql.unsafe(`
|
|
3666
|
+
SELECT m.*, (SELECT u.name FROM ${self.tUsers} u WHERE u.id = m.sender_id) AS sender_name
|
|
3667
|
+
FROM ${self.q("messages")} m
|
|
3668
|
+
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL AND m.created_at < $2
|
|
3669
|
+
ORDER BY m.created_at DESC LIMIT $3
|
|
3670
|
+
`, [conversationId, cursor.created_at, limit]);
|
|
3671
|
+
} else {
|
|
3672
|
+
rows = [];
|
|
3673
|
+
}
|
|
3674
|
+
} else {
|
|
3675
|
+
rows = await sql.unsafe(`
|
|
3676
|
+
SELECT m.*, (SELECT u.name FROM ${self.tUsers} u WHERE u.id = m.sender_id) AS sender_name
|
|
3677
|
+
FROM ${self.q("messages")} m
|
|
3678
|
+
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
|
|
3679
|
+
ORDER BY m.created_at DESC LIMIT $2
|
|
3680
|
+
`, [conversationId, limit]);
|
|
3681
|
+
}
|
|
3682
|
+
return rows.map(toMessage).reverse();
|
|
3683
|
+
},
|
|
3684
|
+
async editMessage(messageId, body) {
|
|
3685
|
+
const me = currentUserId3();
|
|
3686
|
+
if (!body.trim()) throw new Error("Message body cannot be empty");
|
|
3687
|
+
await self.ensureMigrated(sql);
|
|
3688
|
+
const [msg] = await sql.unsafe(`
|
|
3689
|
+
SELECT * FROM ${self.q("messages")} WHERE id = $1 AND sender_id = $2 AND deleted_at IS NULL
|
|
3690
|
+
`, [messageId, me]);
|
|
3691
|
+
if (!msg) return null;
|
|
3692
|
+
const elapsed = Date.now() - new Date(msg.created_at).getTime();
|
|
3693
|
+
if (elapsed > EDIT_WINDOW_MS) return null;
|
|
3694
|
+
const [updated] = await sql.unsafe(`
|
|
3695
|
+
UPDATE ${self.q("messages")} SET body = $1, edited = TRUE, updated_at = NOW()
|
|
3696
|
+
WHERE id = $2 RETURNING *
|
|
3697
|
+
`, [body.trim(), messageId]);
|
|
3698
|
+
const message = toMessage(updated);
|
|
3699
|
+
broadcast(msg.conversation_id, "edit_message", { message });
|
|
3700
|
+
return message;
|
|
3701
|
+
},
|
|
3702
|
+
async deleteMessage(messageId) {
|
|
3703
|
+
const me = currentUserId3();
|
|
3704
|
+
await self.ensureMigrated(sql);
|
|
3705
|
+
const [msg] = await sql.unsafe(`
|
|
3706
|
+
SELECT * FROM ${self.q("messages")} WHERE id = $1 AND sender_id = $2 AND deleted_at IS NULL
|
|
3707
|
+
`, [messageId, me]);
|
|
3708
|
+
if (!msg) return false;
|
|
3709
|
+
await sql.unsafe(`
|
|
3710
|
+
UPDATE ${self.q("messages")} SET deleted_at = NOW() WHERE id = $1
|
|
3711
|
+
`, [messageId]);
|
|
3712
|
+
broadcast(msg.conversation_id, "delete_message", { messageId });
|
|
3713
|
+
return true;
|
|
3714
|
+
},
|
|
3715
|
+
// ── Read state ──────────────────────────────────────
|
|
3716
|
+
async markRead(conversationId) {
|
|
3717
|
+
const me = currentUserId3();
|
|
3718
|
+
await self.ensureMigrated(sql);
|
|
3719
|
+
await sql.unsafe(`
|
|
3720
|
+
INSERT INTO ${self.q("participants")} (conversation_id, user_id, role, last_read_at)
|
|
3721
|
+
VALUES ($1, $2, 'member', NOW())
|
|
3722
|
+
ON CONFLICT (conversation_id, user_id)
|
|
3723
|
+
DO UPDATE SET last_read_at = NOW()
|
|
3724
|
+
`, [conversationId, me]);
|
|
3725
|
+
},
|
|
3726
|
+
async getUnreadCount() {
|
|
3727
|
+
const me = currentUserId3();
|
|
3728
|
+
await self.ensureMigrated(sql);
|
|
3729
|
+
const rows = await sql.unsafe(`
|
|
3730
|
+
SELECT
|
|
3731
|
+
p.conversation_id,
|
|
3732
|
+
COUNT(m.id)::INT AS cnt
|
|
3733
|
+
FROM ${self.q("participants")} p
|
|
3734
|
+
JOIN ${self.q("messages")} m ON m.conversation_id = p.conversation_id
|
|
3735
|
+
AND m.deleted_at IS NULL
|
|
3736
|
+
AND m.created_at > COALESCE(p.last_read_at, '1970-01-01'::timestamptz)
|
|
3737
|
+
AND m.sender_id != $1
|
|
3738
|
+
WHERE p.user_id = $1 AND p.is_active = TRUE
|
|
3739
|
+
GROUP BY p.conversation_id
|
|
3740
|
+
`, [me]);
|
|
3741
|
+
const byConversation = {};
|
|
3742
|
+
let total = 0;
|
|
3743
|
+
for (const row of rows) {
|
|
3744
|
+
const cnt = row.cnt;
|
|
3745
|
+
byConversation[row.conversation_id] = cnt;
|
|
3746
|
+
total += cnt;
|
|
3747
|
+
}
|
|
3748
|
+
return { total, byConversation };
|
|
3749
|
+
}
|
|
3750
|
+
};
|
|
3751
|
+
}
|
|
3752
|
+
// ── Internal ──────────────────────────────────────────────
|
|
3753
|
+
async _getConversationById(id, sql, userId) {
|
|
3754
|
+
const [row] = await sql.unsafe(`
|
|
3755
|
+
SELECT c.*,
|
|
3756
|
+
(SELECT COUNT(*) FROM ${this.q("participants")} p
|
|
3757
|
+
WHERE p.conversation_id = c.id AND p.is_active = TRUE) AS participant_count
|
|
3758
|
+
FROM ${this.q("conversations")} c
|
|
3759
|
+
JOIN ${this.q("participants")} p ON p.conversation_id = c.id AND p.user_id = $1 AND p.is_active = TRUE
|
|
3760
|
+
WHERE c.id = $2
|
|
3761
|
+
`, [userId, id]);
|
|
3762
|
+
return row ? toConversation(row) : null;
|
|
3763
|
+
}
|
|
3764
|
+
// ── Middleware ─────────────────────────────────────────────
|
|
3765
|
+
async middleware(req, ctx, next) {
|
|
3766
|
+
ctx.messager = this.bind(ctx);
|
|
3767
|
+
return next(req, ctx);
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
|
|
3771
|
+
// src/messager/index.ts
|
|
3772
|
+
function messager(opts) {
|
|
3773
|
+
const module = new Messager(opts);
|
|
3774
|
+
const mw = ((req, ctx, next) => {
|
|
3775
|
+
return module.middleware(req, ctx, next);
|
|
3776
|
+
});
|
|
3777
|
+
mw.__meta = { injects: ["messager"], depends: ["sql", "user"] };
|
|
3778
|
+
return mw;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
// src/base/module.ts
|
|
3782
|
+
var SLOTS = {
|
|
3783
|
+
text: { prefix: "text", count: 64 },
|
|
3784
|
+
number: { prefix: "number", count: 32 },
|
|
3785
|
+
date: { prefix: "date", count: 8 },
|
|
3786
|
+
vector: { prefix: "vector", count: 4 },
|
|
3787
|
+
search: { prefix: "search", count: 4 }
|
|
3788
|
+
};
|
|
3789
|
+
var TYPE_TO_SLOT = {
|
|
3790
|
+
string: "text",
|
|
3791
|
+
text: "text",
|
|
3792
|
+
number: "number",
|
|
3793
|
+
boolean: "text",
|
|
3794
|
+
// stored as 'true'/'false' in text
|
|
3795
|
+
date: "date",
|
|
3796
|
+
datetime: "date",
|
|
3797
|
+
vector: "vector",
|
|
3798
|
+
search: "search",
|
|
3799
|
+
relation: "text",
|
|
3800
|
+
// stores UUID
|
|
3801
|
+
json: "ext"
|
|
3802
|
+
};
|
|
3803
|
+
var DEFAULT_LIMIT2 = 50;
|
|
3804
|
+
var MAX_LIMIT2 = 200;
|
|
3805
|
+
function q(name) {
|
|
3806
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
3807
|
+
}
|
|
3808
|
+
function slugify(name) {
|
|
3809
|
+
return name.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100) || "untitled";
|
|
3810
|
+
}
|
|
3811
|
+
function getSql2(ctx) {
|
|
3812
|
+
const sql = ctx.sql;
|
|
3813
|
+
if (!sql) throw new Error("base() requires postgres() middleware");
|
|
3814
|
+
return sql;
|
|
3815
|
+
}
|
|
3816
|
+
function currentUserId(ctx) {
|
|
3817
|
+
const u = ctx.user;
|
|
3818
|
+
if (!u?.id) throw new Error("base() requires user() middleware");
|
|
3819
|
+
return u.id;
|
|
3820
|
+
}
|
|
3821
|
+
function normalizeValue(val) {
|
|
3822
|
+
if (val === void 0) return null;
|
|
3823
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
3824
|
+
if (Array.isArray(val)) return JSON.stringify(val);
|
|
3825
|
+
if (typeof val === "object" && val !== null) return JSON.stringify(val);
|
|
3826
|
+
return val;
|
|
3827
|
+
}
|
|
3828
|
+
function toBaseDef(row) {
|
|
3829
|
+
let tables = [];
|
|
3830
|
+
const raw = row.tables;
|
|
3831
|
+
if (typeof raw === "string") {
|
|
3832
|
+
try {
|
|
3833
|
+
tables = JSON.parse(raw);
|
|
3834
|
+
} catch {
|
|
3835
|
+
}
|
|
3836
|
+
} else if (Array.isArray(raw)) {
|
|
3837
|
+
tables = raw;
|
|
3838
|
+
}
|
|
3839
|
+
return {
|
|
3840
|
+
id: row.id,
|
|
3841
|
+
name: row.name,
|
|
3842
|
+
slug: row.slug,
|
|
3843
|
+
description: row.description,
|
|
3844
|
+
tables,
|
|
3845
|
+
created_by: row.created_by,
|
|
3846
|
+
created_at: row.created_at,
|
|
3847
|
+
updated_at: row.updated_at
|
|
3848
|
+
};
|
|
3849
|
+
}
|
|
3850
|
+
var Base = class {
|
|
3851
|
+
migrated = false;
|
|
3852
|
+
hasVector = false;
|
|
3853
|
+
managementSchema;
|
|
3854
|
+
usersTable;
|
|
3855
|
+
constructor(opts) {
|
|
3856
|
+
this.managementSchema = opts?.managementSchema ?? "public";
|
|
3857
|
+
this.usersTable = opts?.usersTable ?? "users";
|
|
3858
|
+
}
|
|
3859
|
+
ms(name) {
|
|
3860
|
+
return `${q(this.managementSchema)}.${q(name)}`;
|
|
3861
|
+
}
|
|
3862
|
+
// ── Migration ──────────────────────────────────────────────
|
|
3863
|
+
async migrate(sql) {
|
|
3864
|
+
if (this.migrated) return;
|
|
3865
|
+
await sql.unsafe(`
|
|
3866
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("base_bases")} (
|
|
3867
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3868
|
+
name TEXT NOT NULL,
|
|
3869
|
+
slug TEXT NOT NULL UNIQUE,
|
|
3870
|
+
description TEXT,
|
|
3871
|
+
tables JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
3872
|
+
created_by UUID NOT NULL,
|
|
3873
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3874
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3875
|
+
)
|
|
3876
|
+
`);
|
|
3877
|
+
await sql.unsafe(`
|
|
3878
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("base_column_map")} (
|
|
3879
|
+
base_id UUID NOT NULL,
|
|
3880
|
+
table_name TEXT NOT NULL,
|
|
3881
|
+
field_name TEXT NOT NULL,
|
|
3882
|
+
field_type TEXT NOT NULL,
|
|
3883
|
+
physical TEXT NOT NULL,
|
|
3884
|
+
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
3885
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3886
|
+
PRIMARY KEY (base_id, table_name, field_name),
|
|
3887
|
+
UNIQUE (base_id, table_name, physical)
|
|
3888
|
+
)
|
|
3889
|
+
`);
|
|
3890
|
+
const textCols = Array.from({ length: SLOTS.text.count }, (_, i) => `text${String(i + 1).padStart(3, "0")} TEXT`);
|
|
3891
|
+
const numCols = Array.from({ length: SLOTS.number.count }, (_, i) => `number${String(i + 1).padStart(3, "0")} DOUBLE PRECISION`);
|
|
3892
|
+
const dateCols = Array.from({ length: SLOTS.date.count }, (_, i) => `date${String(i + 1).padStart(3, "0")} TIMESTAMPTZ`);
|
|
3893
|
+
await sql.unsafe(`CREATE EXTENSION IF NOT EXISTS vector`);
|
|
3894
|
+
let hasVector = false;
|
|
3895
|
+
try {
|
|
3896
|
+
const extRows = await sql.unsafe(`SELECT 1 FROM pg_extension WHERE extname = 'vector'`);
|
|
3897
|
+
hasVector = extRows.length > 0;
|
|
3898
|
+
} catch {
|
|
3899
|
+
}
|
|
3900
|
+
const vectorCols = hasVector ? Array.from({ length: SLOTS.vector.count }, (_, i) => `vector${String(i + 1).padStart(3, "0")} VECTOR(1536)`) : [];
|
|
3901
|
+
const searchCols = Array.from({ length: SLOTS.search.count }, (_, i) => `search${String(i + 1).padStart(3, "0")} TEXT`);
|
|
3902
|
+
const allCols = [...textCols, ...numCols, ...dateCols, ...vectorCols, ...searchCols];
|
|
3903
|
+
await sql.unsafe(
|
|
3904
|
+
`
|
|
3905
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("base_data")} (
|
|
3906
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3907
|
+
base_id UUID NOT NULL,
|
|
3908
|
+
table_name TEXT NOT NULL,
|
|
3909
|
+
${allCols.join(",\n ")},
|
|
3910
|
+
ext JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
3911
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
3912
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3913
|
+
)`
|
|
3914
|
+
);
|
|
3915
|
+
await sql.unsafe(`
|
|
3916
|
+
CREATE INDEX IF NOT EXISTS base_data_base_table_idx
|
|
3917
|
+
ON ${this.ms("base_data")} (base_id, table_name)
|
|
3918
|
+
`);
|
|
3919
|
+
if (hasVector) {
|
|
3920
|
+
await sql.unsafe(`
|
|
3921
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("base_vectors")} (
|
|
3922
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3923
|
+
base_id UUID NOT NULL,
|
|
3924
|
+
table_name TEXT NOT NULL,
|
|
3925
|
+
field_name TEXT NOT NULL,
|
|
3926
|
+
row_id UUID NOT NULL,
|
|
3927
|
+
embedding VECTOR(1536) NOT NULL,
|
|
3928
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3929
|
+
)
|
|
3930
|
+
`);
|
|
3931
|
+
await sql.unsafe(`
|
|
3932
|
+
CREATE INDEX IF NOT EXISTS base_vectors_lookup_idx
|
|
3933
|
+
ON ${this.ms("base_vectors")} (base_id, table_name, field_name)
|
|
3934
|
+
`);
|
|
3935
|
+
}
|
|
3936
|
+
await sql.unsafe(`
|
|
3937
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("base_search")} (
|
|
3938
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
3939
|
+
base_id UUID NOT NULL,
|
|
3940
|
+
table_name TEXT NOT NULL,
|
|
3941
|
+
field_name TEXT NOT NULL,
|
|
3942
|
+
row_id UUID NOT NULL,
|
|
3943
|
+
content TEXT NOT NULL,
|
|
3944
|
+
tsv TSVECTOR NOT NULL,
|
|
3945
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3946
|
+
)
|
|
3947
|
+
`);
|
|
3948
|
+
await sql.unsafe(`
|
|
3949
|
+
CREATE INDEX IF NOT EXISTS base_search_tsv_idx
|
|
3950
|
+
ON ${this.ms("base_search")} USING GIN (tsv)
|
|
3951
|
+
`);
|
|
3952
|
+
await sql.unsafe(`
|
|
3953
|
+
CREATE INDEX IF NOT EXISTS base_search_lookup_idx
|
|
3954
|
+
ON ${this.ms("base_search")} (base_id, table_name, field_name)
|
|
3955
|
+
`);
|
|
3956
|
+
this.migrated = true;
|
|
3957
|
+
this.hasVector = hasVector;
|
|
3958
|
+
}
|
|
3959
|
+
async ensureMigrated(sql) {
|
|
3960
|
+
if (!this.migrated) await this.migrate(sql);
|
|
3961
|
+
}
|
|
3962
|
+
// ── Slot allocator ─────────────────────────────────────────
|
|
3963
|
+
async allocateSlot(sql, baseId, tableName, fieldType) {
|
|
3964
|
+
const slotGroup = TYPE_TO_SLOT[fieldType];
|
|
3965
|
+
if (!slotGroup || slotGroup === "ext") {
|
|
3966
|
+
return "ext";
|
|
3967
|
+
}
|
|
3968
|
+
const range = SLOTS[slotGroup];
|
|
3969
|
+
if (!range) return "ext";
|
|
3970
|
+
const used = await sql.unsafe(
|
|
3971
|
+
`SELECT physical FROM ${this.ms("base_column_map")}
|
|
3972
|
+
WHERE base_id = $1 AND table_name = $2 AND physical LIKE $3`,
|
|
3973
|
+
[baseId, tableName, `${range.prefix}%`]
|
|
3974
|
+
);
|
|
3975
|
+
const usedSet = new Set(used.map((r) => r.physical));
|
|
3976
|
+
for (let i = 1; i <= range.count; i++) {
|
|
3977
|
+
const candidate = `${range.prefix}${String(i).padStart(3, "0")}`;
|
|
3978
|
+
if (!usedSet.has(candidate)) {
|
|
3979
|
+
return candidate;
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
return "ext";
|
|
3983
|
+
}
|
|
3984
|
+
// ── Physical column type ───────────────────────────────────
|
|
3985
|
+
physicalType(slotName, _fieldType) {
|
|
3986
|
+
if (slotName.startsWith("text") || slotName.startsWith("search")) {
|
|
3987
|
+
return "text";
|
|
3988
|
+
}
|
|
3989
|
+
if (slotName.startsWith("number")) return "number";
|
|
3990
|
+
if (slotName.startsWith("date")) return "date";
|
|
3991
|
+
if (slotName.startsWith("vector")) return "vector";
|
|
3992
|
+
return "jsonb";
|
|
3993
|
+
}
|
|
3994
|
+
// ── Get column map for a table ─────────────────────────────
|
|
3995
|
+
async getColumnMap(sql, baseId, tableName) {
|
|
3996
|
+
const rows = await sql.unsafe(
|
|
3997
|
+
`SELECT field_name, field_type, physical
|
|
3998
|
+
FROM ${this.ms("base_column_map")}
|
|
3999
|
+
WHERE base_id = $1 AND table_name = $2
|
|
4000
|
+
ORDER BY physical`,
|
|
4001
|
+
[baseId, tableName]
|
|
4002
|
+
);
|
|
4003
|
+
return rows.map((r) => ({
|
|
4004
|
+
field_name: r.field_name,
|
|
4005
|
+
physical: r.physical,
|
|
4006
|
+
field_type: r.field_type
|
|
4007
|
+
}));
|
|
4008
|
+
}
|
|
4009
|
+
// ── Convert data row to field/value pairs ──────────────────
|
|
4010
|
+
splitFields(colMap, data) {
|
|
4011
|
+
const slotValues = {};
|
|
4012
|
+
const extValues = {};
|
|
4013
|
+
for (const [key, value] of Object.entries(data)) {
|
|
4014
|
+
if (key === "id") continue;
|
|
4015
|
+
if (value === void 0) continue;
|
|
4016
|
+
const mapping = colMap.find((m) => m.field_name === key);
|
|
4017
|
+
if (mapping && mapping.physical !== "ext") {
|
|
4018
|
+
slotValues[mapping.physical] = value;
|
|
4019
|
+
} else {
|
|
4020
|
+
extValues[key] = value;
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
return { slotValues, extValues };
|
|
4024
|
+
}
|
|
4025
|
+
// ── Convert row to field/value pairs (reverse) ─────────────
|
|
4026
|
+
reconstructRow(colMap, row) {
|
|
4027
|
+
const result = { ...row };
|
|
4028
|
+
for (const m of colMap) {
|
|
4029
|
+
if (m.physical !== "ext") {
|
|
4030
|
+
if (m.physical in result) {
|
|
4031
|
+
let val = result[m.physical];
|
|
4032
|
+
if (m.field_type === "boolean") {
|
|
4033
|
+
val = val === "true" || val === true;
|
|
4034
|
+
} else if (m.field_type === "number") {
|
|
4035
|
+
if (typeof val === "string") val = parseFloat(val);
|
|
4036
|
+
}
|
|
4037
|
+
result[m.field_name] = val;
|
|
4038
|
+
delete result[m.physical];
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
if (result.ext) {
|
|
4043
|
+
let extData = result.ext;
|
|
4044
|
+
if (typeof extData === "string") {
|
|
4045
|
+
try {
|
|
4046
|
+
extData = JSON.parse(extData);
|
|
4047
|
+
} catch {
|
|
4048
|
+
extData = {};
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
if (typeof extData === "object" && extData !== null) {
|
|
4052
|
+
for (const [k, v] of Object.entries(extData)) {
|
|
4053
|
+
result[k] = v;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
delete result.ext;
|
|
4058
|
+
return result;
|
|
4059
|
+
}
|
|
4060
|
+
// ── Build WHERE clause from filter ─────────────────────────
|
|
4061
|
+
buildFilter(colMap, filter, startAt = 1) {
|
|
4062
|
+
const conditions = [];
|
|
4063
|
+
const values = [];
|
|
4064
|
+
let idx = startAt;
|
|
4065
|
+
for (const [key, val] of Object.entries(filter)) {
|
|
4066
|
+
if (val === void 0 || val === null) continue;
|
|
4067
|
+
const mapping = colMap.find((m) => m.field_name === key);
|
|
4068
|
+
if (mapping && mapping.physical !== "ext") {
|
|
4069
|
+
conditions.push(`${q(mapping.physical)}::text = $${idx++}`);
|
|
4070
|
+
values.push(val);
|
|
4071
|
+
} else {
|
|
4072
|
+
conditions.push(`ext @> $${idx++}::jsonb`);
|
|
4073
|
+
values.push(JSON.stringify({ [key]: val }));
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
return { conditions, values };
|
|
4077
|
+
}
|
|
4078
|
+
// ── Per-request bound API ──────────────────────────────────
|
|
4079
|
+
bind(ctx) {
|
|
4080
|
+
const self = this;
|
|
4081
|
+
const sql = getSql2(ctx);
|
|
4082
|
+
if (!this.migrated) {
|
|
4083
|
+
this.migrate(sql).catch(() => {
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
async function ensureBase(baseId) {
|
|
4087
|
+
const [row] = await sql.unsafe(
|
|
4088
|
+
`SELECT * FROM ${self.ms("base_bases")} WHERE id = $1 LIMIT 1`,
|
|
4089
|
+
[baseId]
|
|
4090
|
+
);
|
|
4091
|
+
if (!row) throw new Error("Base not found");
|
|
4092
|
+
return row;
|
|
4093
|
+
}
|
|
4094
|
+
return {
|
|
4095
|
+
// ── Create ──────────────────────────────────────────
|
|
4096
|
+
async create(input) {
|
|
4097
|
+
const userId = currentUserId(ctx);
|
|
4098
|
+
await self.ensureMigrated(sql);
|
|
4099
|
+
const slug = input.slug || slugify(input.name);
|
|
4100
|
+
const tables = input.tables || [];
|
|
4101
|
+
const [baseRow] = await sql.unsafe(
|
|
4102
|
+
`
|
|
4103
|
+
INSERT INTO ${self.ms("base_bases")} (name, slug, description, tables, created_by)
|
|
4104
|
+
VALUES ($1, $2, $3, $4, $5) RETURNING *
|
|
4105
|
+
`,
|
|
4106
|
+
[input.name, slug, input.description ?? null, tables, userId]
|
|
4107
|
+
);
|
|
4108
|
+
for (const table of tables) {
|
|
4109
|
+
for (const [fieldName, fieldSchema] of Object.entries(table.fields)) {
|
|
4110
|
+
const physical = await self.allocateSlot(sql, baseRow.id, table.name, fieldSchema.type);
|
|
4111
|
+
await sql.unsafe(`
|
|
4112
|
+
INSERT INTO ${self.ms("base_column_map")} (base_id, table_name, field_name, field_type, physical, config)
|
|
4113
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
4114
|
+
`, [baseRow.id, table.name, fieldName, fieldSchema.type, physical, fieldSchema]);
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
return toBaseDef(baseRow);
|
|
4118
|
+
},
|
|
4119
|
+
// ── List ────────────────────────────────────────────
|
|
4120
|
+
async list() {
|
|
4121
|
+
await self.ensureMigrated(sql);
|
|
4122
|
+
const rows = await sql.unsafe(
|
|
4123
|
+
`SELECT * FROM ${self.ms("base_bases")} ORDER BY created_at DESC`
|
|
4124
|
+
);
|
|
4125
|
+
return rows.map(toBaseDef);
|
|
4126
|
+
},
|
|
4127
|
+
// ── Get ─────────────────────────────────────────────
|
|
4128
|
+
async get(id) {
|
|
4129
|
+
await self.ensureMigrated(sql);
|
|
4130
|
+
const [row] = await sql.unsafe(
|
|
4131
|
+
`SELECT * FROM ${self.ms("base_bases")} WHERE id = $1 LIMIT 1`,
|
|
4132
|
+
[id]
|
|
4133
|
+
);
|
|
4134
|
+
return row ? toBaseDef(row) : null;
|
|
4135
|
+
},
|
|
4136
|
+
async getBySlug(slug) {
|
|
4137
|
+
await self.ensureMigrated(sql);
|
|
4138
|
+
const [row] = await sql.unsafe(
|
|
4139
|
+
`SELECT * FROM ${self.ms("base_bases")} WHERE slug = $1 LIMIT 1`,
|
|
4140
|
+
[slug]
|
|
4141
|
+
);
|
|
4142
|
+
return row ? toBaseDef(row) : null;
|
|
4143
|
+
},
|
|
4144
|
+
// ── Update ──────────────────────────────────────────
|
|
4145
|
+
async update(id, input) {
|
|
4146
|
+
await self.ensureMigrated(sql);
|
|
4147
|
+
const sets = [];
|
|
4148
|
+
const values = [];
|
|
4149
|
+
let idx = 1;
|
|
4150
|
+
if (input.name !== void 0) {
|
|
4151
|
+
sets.push(`name = $${idx++}`);
|
|
4152
|
+
values.push(input.name);
|
|
4153
|
+
}
|
|
4154
|
+
if (input.description !== void 0) {
|
|
4155
|
+
sets.push(`description = $${idx++}`);
|
|
4156
|
+
values.push(input.description);
|
|
4157
|
+
}
|
|
4158
|
+
if (sets.length === 0) return null;
|
|
4159
|
+
sets.push("updated_at = NOW()");
|
|
4160
|
+
values.push(id);
|
|
4161
|
+
const [row] = await sql.unsafe(
|
|
4162
|
+
`UPDATE ${self.ms("base_bases")} SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
|
4163
|
+
values
|
|
4164
|
+
);
|
|
4165
|
+
return row ? toBaseDef(row) : null;
|
|
4166
|
+
},
|
|
4167
|
+
// ── Delete ──────────────────────────────────────────
|
|
4168
|
+
async delete(id) {
|
|
4169
|
+
await self.ensureMigrated(sql);
|
|
4170
|
+
const [base2] = await sql.unsafe(`SELECT id FROM ${self.ms("base_bases")} WHERE id = $1 LIMIT 1`, [id]);
|
|
4171
|
+
if (!base2) return false;
|
|
4172
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_data")} WHERE base_id = $1`, [id]);
|
|
4173
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_vectors")} WHERE base_id = $1`, [id]);
|
|
4174
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_search")} WHERE base_id = $1`, [id]);
|
|
4175
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_column_map")} WHERE base_id = $1`, [id]);
|
|
4176
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_bases")} WHERE id = $1`, [id]);
|
|
4177
|
+
return true;
|
|
4178
|
+
},
|
|
4179
|
+
// ── Define table ─────────────────────────────────────
|
|
4180
|
+
async defineTable(baseId, schema) {
|
|
4181
|
+
await self.ensureMigrated(sql);
|
|
4182
|
+
const baseRow = await ensureBase(baseId);
|
|
4183
|
+
const tables = Array.isArray(baseRow.tables) ? baseRow.tables : [];
|
|
4184
|
+
if (tables.some((t) => t.name === schema.name)) {
|
|
4185
|
+
throw new Error(`Table "${schema.name}" already exists`);
|
|
4186
|
+
}
|
|
4187
|
+
for (const [fieldName, fieldSchema] of Object.entries(schema.fields)) {
|
|
4188
|
+
const physical = await self.allocateSlot(sql, baseId, schema.name, fieldSchema.type);
|
|
4189
|
+
await sql.unsafe(`
|
|
4190
|
+
INSERT INTO ${self.ms("base_column_map")} (base_id, table_name, field_name, field_type, physical, config)
|
|
4191
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
4192
|
+
`, [baseId, schema.name, fieldName, fieldSchema.type, physical, fieldSchema]);
|
|
4193
|
+
}
|
|
4194
|
+
const newTables = [...tables, schema];
|
|
4195
|
+
const [row] = await sql.unsafe(
|
|
4196
|
+
`UPDATE ${self.ms("base_bases")} SET tables = $1, updated_at = NOW() WHERE id = $2 RETURNING *`,
|
|
4197
|
+
[newTables, baseId]
|
|
4198
|
+
);
|
|
4199
|
+
return toBaseDef(row);
|
|
4200
|
+
},
|
|
4201
|
+
// ── Update table ─────────────────────────────────────
|
|
4202
|
+
async updateTable(baseId, tableName, schema) {
|
|
4203
|
+
await self.ensureMigrated(sql);
|
|
4204
|
+
const baseRow = await ensureBase(baseId);
|
|
4205
|
+
const tables = Array.isArray(baseRow.tables) ? baseRow.tables : [];
|
|
4206
|
+
const idx = tables.findIndex((t) => t.name === tableName);
|
|
4207
|
+
if (idx === -1) throw new Error(`Table "${tableName}" not found`);
|
|
4208
|
+
const existing = tables[idx];
|
|
4209
|
+
const existingFields = Object.keys(existing.fields);
|
|
4210
|
+
for (const [fieldName, fieldSchema] of Object.entries(schema.fields)) {
|
|
4211
|
+
if (!existingFields.includes(fieldName)) {
|
|
4212
|
+
const physical = await self.allocateSlot(sql, baseId, tableName, fieldSchema.type);
|
|
4213
|
+
await sql.unsafe(`
|
|
4214
|
+
INSERT INTO ${self.ms("base_column_map")} (base_id, table_name, field_name, field_type, physical, config)
|
|
4215
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
4216
|
+
ON CONFLICT (base_id, table_name, field_name) DO UPDATE SET field_type = $4, config = $6
|
|
4217
|
+
`, [baseId, tableName, fieldName, fieldSchema.type, physical, fieldSchema]);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
const newTables = [...tables];
|
|
4221
|
+
newTables[idx] = schema;
|
|
4222
|
+
const [row] = await sql.unsafe(
|
|
4223
|
+
`UPDATE ${self.ms("base_bases")} SET tables = $1, updated_at = NOW() WHERE id = $2 RETURNING *`,
|
|
4224
|
+
[newTables, baseId]
|
|
4225
|
+
);
|
|
4226
|
+
return toBaseDef(row);
|
|
4227
|
+
},
|
|
4228
|
+
// ── Remove table ─────────────────────────────────────
|
|
4229
|
+
async removeTable(baseId, tableName) {
|
|
4230
|
+
await self.ensureMigrated(sql);
|
|
4231
|
+
const baseRow = await ensureBase(baseId);
|
|
4232
|
+
const tables = Array.isArray(baseRow.tables) ? baseRow.tables : [];
|
|
4233
|
+
if (!tables.some((t) => t.name === tableName)) return null;
|
|
4234
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_data")} WHERE base_id = $1 AND table_name = $2`, [baseId, tableName]);
|
|
4235
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_vectors")} WHERE base_id = $1 AND table_name = $2`, [baseId, tableName]);
|
|
4236
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_search")} WHERE base_id = $1 AND table_name = $2`, [baseId, tableName]);
|
|
4237
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_column_map")} WHERE base_id = $1 AND table_name = $2`, [baseId, tableName]);
|
|
4238
|
+
const newTables = tables.filter((t) => t.name !== tableName);
|
|
4239
|
+
const [row] = await sql.unsafe(
|
|
4240
|
+
`UPDATE ${self.ms("base_bases")} SET tables = $1, updated_at = NOW() WHERE id = $2 RETURNING *`,
|
|
4241
|
+
[newTables, baseId]
|
|
4242
|
+
);
|
|
4243
|
+
return toBaseDef(row);
|
|
4244
|
+
},
|
|
4245
|
+
// ── Insert ──────────────────────────────────────────
|
|
4246
|
+
async insert(baseId, table, data) {
|
|
4247
|
+
await self.ensureMigrated(sql);
|
|
4248
|
+
await ensureBase(baseId);
|
|
4249
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4250
|
+
const { slotValues, extValues } = self.splitFields(colMap, data);
|
|
4251
|
+
const cols = ["base_id", "table_name"];
|
|
4252
|
+
const vals = [baseId, table];
|
|
4253
|
+
for (const [phys, val] of Object.entries(slotValues)) {
|
|
4254
|
+
cols.push(q(phys));
|
|
4255
|
+
vals.push(normalizeValue(val));
|
|
4256
|
+
}
|
|
4257
|
+
cols.push("ext");
|
|
4258
|
+
vals.push(JSON.stringify(extValues));
|
|
4259
|
+
const placeholders = vals.map((_, i) => `$${i + 1}`);
|
|
4260
|
+
const [row] = await sql.unsafe(`
|
|
4261
|
+
INSERT INTO ${self.ms("base_data")} (${cols.join(", ")})
|
|
4262
|
+
VALUES (${placeholders.join(", ")})
|
|
4263
|
+
RETURNING *
|
|
4264
|
+
`, vals);
|
|
4265
|
+
for (const [fieldName, val] of Object.entries(data)) {
|
|
4266
|
+
if (fieldName === "id") continue;
|
|
4267
|
+
const mapping = colMap.find((m) => m.field_name === fieldName);
|
|
4268
|
+
if (!mapping) continue;
|
|
4269
|
+
if (mapping.field_type === "vector" && Array.isArray(val) && self.hasVector) {
|
|
4270
|
+
await sql.unsafe(`
|
|
4271
|
+
INSERT INTO ${self.ms("base_vectors")} (base_id, table_name, field_name, row_id, embedding)
|
|
4272
|
+
VALUES ($1, $2, $3, $4, $5::vector)
|
|
4273
|
+
ON CONFLICT DO NOTHING
|
|
4274
|
+
`, [baseId, table, fieldName, row.id, JSON.stringify(val)]);
|
|
4275
|
+
}
|
|
4276
|
+
if (mapping.field_type === "search" && typeof val === "string") {
|
|
4277
|
+
await sql.unsafe(`
|
|
4278
|
+
INSERT INTO ${self.ms("base_search")} (base_id, table_name, field_name, row_id, content, tsv)
|
|
4279
|
+
VALUES ($1, $2, $3, $4, $5, to_tsvector('simple', $5))
|
|
4280
|
+
ON CONFLICT DO NOTHING
|
|
4281
|
+
`, [baseId, table, fieldName, row.id, val]);
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
return self.reconstructRow(colMap, row);
|
|
4285
|
+
},
|
|
4286
|
+
// ── Get row ──────────────────────────────────────────
|
|
4287
|
+
async getRow(baseId, table, id) {
|
|
4288
|
+
await self.ensureMigrated(sql);
|
|
4289
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4290
|
+
const [row] = await sql.unsafe(
|
|
4291
|
+
`SELECT * FROM ${self.ms("base_data")} WHERE base_id = $1 AND table_name = $2 AND id = $3 LIMIT 1`,
|
|
4292
|
+
[baseId, table, id]
|
|
4293
|
+
);
|
|
4294
|
+
if (!row) return null;
|
|
4295
|
+
return self.reconstructRow(colMap, row);
|
|
4296
|
+
},
|
|
4297
|
+
// ── Update row ───────────────────────────────────────
|
|
4298
|
+
async updateRow(baseId, table, id, data) {
|
|
4299
|
+
await self.ensureMigrated(sql);
|
|
4300
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4301
|
+
const { slotValues, extValues } = self.splitFields(colMap, data);
|
|
4302
|
+
const sets = ["updated_at = NOW()"];
|
|
4303
|
+
const vals = [];
|
|
4304
|
+
let idx = 1;
|
|
4305
|
+
for (const [phys, val] of Object.entries(slotValues)) {
|
|
4306
|
+
sets.push(`${q(phys)} = $${idx++}`);
|
|
4307
|
+
vals.push(normalizeValue(val));
|
|
4308
|
+
}
|
|
4309
|
+
if (Object.keys(extValues).length > 0) {
|
|
4310
|
+
sets.push(`ext = ext || $${idx++}::jsonb`);
|
|
4311
|
+
vals.push(JSON.stringify(extValues));
|
|
4312
|
+
}
|
|
4313
|
+
if (sets.length === 1) {
|
|
4314
|
+
return null;
|
|
4315
|
+
}
|
|
4316
|
+
vals.push(baseId, table, id);
|
|
4317
|
+
const [row] = await sql.unsafe(`
|
|
4318
|
+
UPDATE ${self.ms("base_data")} SET ${sets.join(", ")}
|
|
4319
|
+
WHERE base_id = $${idx} AND table_name = $${idx + 1} AND id = $${idx + 2}
|
|
4320
|
+
RETURNING *
|
|
4321
|
+
`, vals);
|
|
4322
|
+
if (!row) return null;
|
|
4323
|
+
return self.reconstructRow(colMap, row);
|
|
4324
|
+
},
|
|
4325
|
+
// ── Delete row ───────────────────────────────────────
|
|
4326
|
+
async deleteRow(baseId, table, id) {
|
|
4327
|
+
await self.ensureMigrated(sql);
|
|
4328
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_vectors")} WHERE base_id = $1 AND table_name = $2 AND row_id = $3`, [baseId, table, id]);
|
|
4329
|
+
await sql.unsafe(`DELETE FROM ${self.ms("base_search")} WHERE base_id = $1 AND table_name = $2 AND row_id = $3`, [baseId, table, id]);
|
|
4330
|
+
const [row] = await sql.unsafe(
|
|
4331
|
+
`DELETE FROM ${self.ms("base_data")} WHERE base_id = $1 AND table_name = $2 AND id = $3 RETURNING id`,
|
|
4332
|
+
[baseId, table, id]
|
|
4333
|
+
);
|
|
4334
|
+
return !!row;
|
|
4335
|
+
},
|
|
4336
|
+
// ── Query ────────────────────────────────────────────
|
|
4337
|
+
async query(baseId, table, opts) {
|
|
4338
|
+
await self.ensureMigrated(sql);
|
|
4339
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4340
|
+
const limit = Math.min(opts?.limit ?? DEFAULT_LIMIT2, MAX_LIMIT2);
|
|
4341
|
+
const offset = opts?.offset ?? 0;
|
|
4342
|
+
const sortCol = opts?.sort ? colMap.find((m) => m.field_name === opts.sort)?.physical || opts.sort : "created_at";
|
|
4343
|
+
const sortOrder = opts?.order === "desc" ? "DESC" : "ASC";
|
|
4344
|
+
const conditions = ["base_id = $1", "table_name = $2"];
|
|
4345
|
+
const values = [baseId, table];
|
|
4346
|
+
if (opts?.filter) {
|
|
4347
|
+
const { conditions: fc, values: fv } = self.buildFilter(colMap, opts.filter, values.length + 1);
|
|
4348
|
+
conditions.push(...fc);
|
|
4349
|
+
values.push(...fv);
|
|
4350
|
+
}
|
|
4351
|
+
const where = conditions.join(" AND ");
|
|
4352
|
+
const paramIdx = values.length + 1;
|
|
4353
|
+
const rows = await sql.unsafe(`
|
|
4354
|
+
SELECT * FROM ${self.ms("base_data")}
|
|
4355
|
+
WHERE ${where}
|
|
4356
|
+
ORDER BY ${q(sortCol)} ${sortOrder}
|
|
4357
|
+
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
|
|
4358
|
+
`, [...values, limit, offset]);
|
|
4359
|
+
const result = rows.map((r) => self.reconstructRow(colMap, r));
|
|
4360
|
+
if (opts?.include && opts.include.length > 0) {
|
|
4361
|
+
const relMap = {};
|
|
4362
|
+
for (const inc of opts.include) {
|
|
4363
|
+
relMap[inc] = /* @__PURE__ */ new Set();
|
|
4364
|
+
}
|
|
4365
|
+
for (const row of result) {
|
|
4366
|
+
for (const inc of opts.include) {
|
|
4367
|
+
const val = row[inc];
|
|
4368
|
+
if (val && typeof val === "string") relMap[inc].add(val);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
for (const inc of opts.include) {
|
|
4372
|
+
const mapping = colMap.find((m) => m.field_name === inc);
|
|
4373
|
+
if (!mapping || !mapping.field_type.startsWith("relation")) continue;
|
|
4374
|
+
const ids = [...relMap[inc]];
|
|
4375
|
+
if (ids.length === 0) continue;
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
return result;
|
|
4379
|
+
},
|
|
4380
|
+
// ── Vector similarity search ─────────────────────────
|
|
4381
|
+
async similaritySearch(baseId, table, field, vector, opts) {
|
|
4382
|
+
await self.ensureMigrated(sql);
|
|
4383
|
+
if (!self.hasVector) throw new Error("pgvector extension is not installed");
|
|
4384
|
+
const limit = opts?.limit ?? 10;
|
|
4385
|
+
const rows = await sql.unsafe(`
|
|
4386
|
+
SELECT bd.*, bv.embedding <=> $1::vector AS distance
|
|
4387
|
+
FROM ${self.ms("base_vectors")} bv
|
|
4388
|
+
JOIN ${self.ms("base_data")} bd ON bd.id = bv.row_id
|
|
4389
|
+
WHERE bv.base_id = $2 AND bv.table_name = $3 AND bv.field_name = $4
|
|
4390
|
+
ORDER BY distance ASC
|
|
4391
|
+
LIMIT $5
|
|
4392
|
+
`, [JSON.stringify(vector), baseId, table, field, limit]);
|
|
4393
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4394
|
+
return rows.map((r) => ({
|
|
4395
|
+
...self.reconstructRow(colMap, r),
|
|
4396
|
+
distance: r.distance
|
|
4397
|
+
}));
|
|
4398
|
+
},
|
|
4399
|
+
// ── Full-text search ─────────────────────────────────
|
|
4400
|
+
async search(baseId, table, field, query, opts) {
|
|
4401
|
+
await self.ensureMigrated(sql);
|
|
4402
|
+
const limit = opts?.limit ?? 10;
|
|
4403
|
+
const offset = opts?.offset ?? 0;
|
|
4404
|
+
const rows = await sql.unsafe(`
|
|
4405
|
+
SELECT bd.*,
|
|
4406
|
+
ts_rank(bs.tsv, plainto_tsquery('simple', $1)) AS rank,
|
|
4407
|
+
ts_headline('simple', bs.content, plainto_tsquery('simple', $1),
|
|
4408
|
+
'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=20') AS headline
|
|
4409
|
+
FROM ${self.ms("base_search")} bs
|
|
4410
|
+
JOIN ${self.ms("base_data")} bd ON bd.id = bs.row_id
|
|
4411
|
+
WHERE bs.base_id = $2 AND bs.table_name = $3 AND bs.field_name = $4
|
|
4412
|
+
AND bs.tsv @@ plainto_tsquery('simple', $1)
|
|
4413
|
+
ORDER BY rank DESC
|
|
4414
|
+
LIMIT $5 OFFSET $6
|
|
4415
|
+
`, [query, baseId, table, field, limit, offset]);
|
|
4416
|
+
const colMap = await self.getColumnMap(sql, baseId, table);
|
|
4417
|
+
return rows.map((r) => ({
|
|
4418
|
+
...self.reconstructRow(colMap, r),
|
|
4419
|
+
rank: r.rank,
|
|
4420
|
+
headline: r.headline
|
|
4421
|
+
}));
|
|
4422
|
+
}
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
// ── Middleware ─────────────────────────────────────────────
|
|
4426
|
+
async middleware(req, ctx, next) {
|
|
4427
|
+
ctx.base = this.bind(ctx);
|
|
4428
|
+
return next(req, ctx);
|
|
4429
|
+
}
|
|
4430
|
+
};
|
|
4431
|
+
|
|
4432
|
+
// src/base/index.ts
|
|
4433
|
+
function base(opts) {
|
|
4434
|
+
const module = new Base(opts);
|
|
4435
|
+
const mw = ((req, ctx, next) => {
|
|
4436
|
+
return module.middleware(req, ctx, next);
|
|
4437
|
+
});
|
|
4438
|
+
mw.__meta = { injects: ["base"], depends: ["sql", "user"] };
|
|
4439
|
+
return mw;
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
// src/cms/module.ts
|
|
4443
|
+
var DEFAULT_LIMIT3 = 20;
|
|
4444
|
+
var MAX_LIMIT3 = 100;
|
|
4445
|
+
function slugify2(text) {
|
|
4446
|
+
return text.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 200) || "untitled";
|
|
4447
|
+
}
|
|
4448
|
+
function getSql3(ctx) {
|
|
4449
|
+
const sql = ctx.sql;
|
|
4450
|
+
if (!sql) throw new Error("cms() requires postgres() middleware");
|
|
4451
|
+
return sql;
|
|
4452
|
+
}
|
|
4453
|
+
function toContent(row) {
|
|
4454
|
+
return {
|
|
4455
|
+
id: row.id,
|
|
4456
|
+
slug: row.slug,
|
|
4457
|
+
type: row.type,
|
|
4458
|
+
parent_id: row.parent_id,
|
|
4459
|
+
title: row.title,
|
|
4460
|
+
body: row.body,
|
|
4461
|
+
excerpt: row.excerpt,
|
|
4462
|
+
cover_image: row.cover_image,
|
|
4463
|
+
status: row.status,
|
|
4464
|
+
author_id: row.author_id,
|
|
4465
|
+
author_name: row.author_name,
|
|
4466
|
+
published_at: row.published_at,
|
|
4467
|
+
created_at: row.created_at,
|
|
4468
|
+
updated_at: row.updated_at,
|
|
4469
|
+
tags: row.tags ? row.tags : void 0
|
|
4470
|
+
};
|
|
4471
|
+
}
|
|
4472
|
+
function toTag(row) {
|
|
4473
|
+
return { id: row.id, name: row.name, slug: row.slug };
|
|
4474
|
+
}
|
|
4475
|
+
function toTagWithCount(row) {
|
|
4476
|
+
return { ...toTag(row), content_count: row.content_count };
|
|
4477
|
+
}
|
|
4478
|
+
function currentUserId2(ctx) {
|
|
4479
|
+
const u = ctx.user;
|
|
4480
|
+
if (!u?.id) throw new Error("cms() requires user() middleware \u2014 ctx.user is missing");
|
|
4481
|
+
return u.id;
|
|
4482
|
+
}
|
|
4483
|
+
function requireAdmin(ctx) {
|
|
4484
|
+
const u = ctx.user;
|
|
4485
|
+
if (!u) throw new Error("Authentication required");
|
|
4486
|
+
const role = u.role;
|
|
4487
|
+
if (role !== "admin") throw new Error("Admin role required");
|
|
4488
|
+
}
|
|
4489
|
+
var CMS = class {
|
|
4490
|
+
migrated = false;
|
|
4491
|
+
prefix;
|
|
4492
|
+
usersTable;
|
|
4493
|
+
constructor(opts) {
|
|
4494
|
+
this.prefix = opts?.tablePrefix ?? "";
|
|
4495
|
+
this.usersTable = opts?.usersTable ?? "users";
|
|
4496
|
+
}
|
|
4497
|
+
q(name) {
|
|
4498
|
+
return `"${this.prefix}${name}"`;
|
|
4499
|
+
}
|
|
4500
|
+
// ── Migration ──────────────────────────────────────────────
|
|
4501
|
+
async migrate(sql) {
|
|
4502
|
+
if (this.migrated) return;
|
|
4503
|
+
await sql.unsafe(`
|
|
4504
|
+
CREATE TABLE IF NOT EXISTS ${this.q("contents")} (
|
|
4505
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4506
|
+
slug TEXT NOT NULL,
|
|
4507
|
+
type TEXT NOT NULL DEFAULT 'post',
|
|
4508
|
+
parent_id UUID REFERENCES ${this.q("contents")}(id) ON DELETE SET NULL,
|
|
4509
|
+
title TEXT NOT NULL,
|
|
4510
|
+
body TEXT NOT NULL DEFAULT '',
|
|
4511
|
+
excerpt TEXT,
|
|
4512
|
+
cover_image TEXT,
|
|
4513
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
|
|
4514
|
+
author_id UUID NOT NULL,
|
|
4515
|
+
published_at TIMESTAMPTZ,
|
|
4516
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
4517
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
4518
|
+
)
|
|
4519
|
+
`);
|
|
4520
|
+
await sql.unsafe(`
|
|
4521
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${this.q("contents_slug_type_idx")}
|
|
4522
|
+
ON ${this.q("contents")} (slug, type)
|
|
4523
|
+
`);
|
|
4524
|
+
await sql.unsafe(`
|
|
4525
|
+
CREATE INDEX IF NOT EXISTS ${this.q("contents_type_status_idx")}
|
|
4526
|
+
ON ${this.q("contents")} (type, status, created_at DESC)
|
|
4527
|
+
`);
|
|
4528
|
+
await sql.unsafe(`
|
|
4529
|
+
CREATE INDEX IF NOT EXISTS ${this.q("contents_parent_idx")}
|
|
4530
|
+
ON ${this.q("contents")} (parent_id)
|
|
4531
|
+
`);
|
|
4532
|
+
await sql.unsafe(`
|
|
4533
|
+
CREATE TABLE IF NOT EXISTS ${this.q("tags")} (
|
|
4534
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4535
|
+
name TEXT NOT NULL UNIQUE,
|
|
4536
|
+
slug TEXT NOT NULL UNIQUE
|
|
4537
|
+
)
|
|
4538
|
+
`);
|
|
4539
|
+
await sql.unsafe(`
|
|
4540
|
+
CREATE TABLE IF NOT EXISTS ${this.q("contents_tags")} (
|
|
4541
|
+
content_id UUID NOT NULL REFERENCES ${this.q("contents")}(id) ON DELETE CASCADE,
|
|
4542
|
+
tag_id UUID NOT NULL REFERENCES ${this.q("tags")}(id) ON DELETE CASCADE,
|
|
4543
|
+
PRIMARY KEY (content_id, tag_id)
|
|
4544
|
+
)
|
|
4545
|
+
`);
|
|
4546
|
+
this.migrated = true;
|
|
4547
|
+
}
|
|
4548
|
+
async ensureMigrated(sql) {
|
|
4549
|
+
if (!this.migrated) await this.migrate(sql);
|
|
4550
|
+
}
|
|
4551
|
+
// ── Per-request bound API ──────────────────────────────────
|
|
4552
|
+
bind(ctx) {
|
|
4553
|
+
const self = this;
|
|
4554
|
+
const sql = getSql3(ctx);
|
|
4555
|
+
if (!this.migrated) {
|
|
4556
|
+
this.migrate(sql).catch(() => {
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
async function fetchTags(contentIds) {
|
|
4560
|
+
if (contentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
4561
|
+
const rows = await sql.unsafe(`
|
|
4562
|
+
SELECT ct.content_id, t.id, t.name, t.slug
|
|
4563
|
+
FROM ${self.q("contents_tags")} ct
|
|
4564
|
+
JOIN ${self.q("tags")} t ON t.id = ct.tag_id
|
|
4565
|
+
WHERE ct.content_id = ANY($1::uuid[])
|
|
4566
|
+
ORDER BY t.name
|
|
4567
|
+
`, [contentIds]);
|
|
4568
|
+
const map = /* @__PURE__ */ new Map();
|
|
4569
|
+
for (const row of rows) {
|
|
4570
|
+
const cid = row.content_id;
|
|
4571
|
+
if (!map.has(cid)) map.set(cid, []);
|
|
4572
|
+
map.get(cid).push(toTag(row));
|
|
4573
|
+
}
|
|
4574
|
+
return map;
|
|
4575
|
+
}
|
|
4576
|
+
async function attachTags(contents) {
|
|
4577
|
+
if (contents.length === 0) return contents;
|
|
4578
|
+
const tags = await fetchTags(contents.map((c) => c.id));
|
|
4579
|
+
for (const c of contents) {
|
|
4580
|
+
const t = tags.get(c.id);
|
|
4581
|
+
if (t) c.tags = t;
|
|
4582
|
+
}
|
|
4583
|
+
return contents;
|
|
4584
|
+
}
|
|
4585
|
+
async function updateContentTags(contentId, tagNames) {
|
|
4586
|
+
await sql.unsafe(`DELETE FROM ${self.q("contents_tags")} WHERE content_id = $1`, [contentId]);
|
|
4587
|
+
if (tagNames.length === 0) return;
|
|
4588
|
+
const tagRecords = [];
|
|
4589
|
+
for (const name of tagNames) {
|
|
4590
|
+
const slug = slugify2(name);
|
|
4591
|
+
const [tag] = await sql.unsafe(`
|
|
4592
|
+
INSERT INTO ${self.q("tags")} (name, slug) VALUES ($1, $2)
|
|
4593
|
+
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
|
4594
|
+
RETURNING *
|
|
4595
|
+
`, [name, slug]);
|
|
4596
|
+
if (tag) tagRecords.push(toTag(tag));
|
|
4597
|
+
}
|
|
4598
|
+
if (tagRecords.length > 0) {
|
|
4599
|
+
const values = tagRecords.map((_, i) => `($1, $${i + 2})`).join(", ");
|
|
4600
|
+
await sql.unsafe(`
|
|
4601
|
+
INSERT INTO ${self.q("contents_tags")} (content_id, tag_id)
|
|
4602
|
+
VALUES ${values}
|
|
4603
|
+
ON CONFLICT DO NOTHING
|
|
4604
|
+
`, [contentId, ...tagRecords.map((t) => t.id)]);
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
return {
|
|
4608
|
+
// ── List ─────────────────────────────────────────────
|
|
4609
|
+
async list(opts) {
|
|
4610
|
+
await self.ensureMigrated(sql);
|
|
4611
|
+
const me = ctx.user;
|
|
4612
|
+
const isAdmin = me?.role === "admin";
|
|
4613
|
+
const limit = Math.min(opts?.limit ?? DEFAULT_LIMIT3, MAX_LIMIT3);
|
|
4614
|
+
const conditions = [];
|
|
4615
|
+
const values = [];
|
|
4616
|
+
let idx = 1;
|
|
4617
|
+
if (!isAdmin) {
|
|
4618
|
+
conditions.push(`c.status = 'published'`);
|
|
4619
|
+
} else if (opts?.status) {
|
|
4620
|
+
conditions.push(`c.status = $${idx++}`);
|
|
4621
|
+
values.push(opts.status);
|
|
4622
|
+
}
|
|
4623
|
+
if (opts?.type) {
|
|
4624
|
+
conditions.push(`c.type = $${idx++}`);
|
|
4625
|
+
values.push(opts.type);
|
|
4626
|
+
}
|
|
4627
|
+
if (opts?.tag) {
|
|
4628
|
+
conditions.push(`EXISTS (SELECT 1 FROM ${self.q("contents_tags")} ct
|
|
4629
|
+
JOIN ${self.q("tags")} t ON t.id = ct.tag_id
|
|
4630
|
+
WHERE ct.content_id = c.id AND t.slug = $${idx})`);
|
|
4631
|
+
values.push(opts.tag);
|
|
4632
|
+
idx++;
|
|
4633
|
+
}
|
|
4634
|
+
if (opts?.author_id) {
|
|
4635
|
+
conditions.push(`c.author_id = $${idx++}`);
|
|
4636
|
+
values.push(opts.author_id);
|
|
4637
|
+
}
|
|
4638
|
+
if (opts?.parent_id !== void 0) {
|
|
4639
|
+
if (opts.parent_id === null) {
|
|
4640
|
+
conditions.push("c.parent_id IS NULL");
|
|
4641
|
+
} else {
|
|
4642
|
+
conditions.push(`c.parent_id = $${idx++}`);
|
|
4643
|
+
values.push(opts.parent_id);
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
if (opts?.before) {
|
|
4647
|
+
const [cursor] = await sql.unsafe(
|
|
4648
|
+
`SELECT created_at FROM ${self.q("contents")} WHERE id = $1`,
|
|
4649
|
+
[opts.before]
|
|
4650
|
+
);
|
|
4651
|
+
if (cursor) {
|
|
4652
|
+
conditions.push(`c.created_at < $${idx++}`);
|
|
4653
|
+
values.push(cursor.created_at);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
4657
|
+
const rows = await sql.unsafe(`
|
|
4658
|
+
SELECT c.*, (SELECT u.name FROM "${self.usersTable}" u WHERE u.id = c.author_id) AS author_name
|
|
4659
|
+
FROM ${self.q("contents")} c
|
|
4660
|
+
${where}
|
|
4661
|
+
ORDER BY c.created_at DESC
|
|
4662
|
+
LIMIT $${idx}
|
|
4663
|
+
`, [...values, limit]);
|
|
4664
|
+
const contents = rows.map(toContent);
|
|
4665
|
+
return attachTags(contents);
|
|
4666
|
+
},
|
|
4667
|
+
// ── Get ──────────────────────────────────────────────
|
|
4668
|
+
async get(slug) {
|
|
4669
|
+
await self.ensureMigrated(sql);
|
|
4670
|
+
const me = ctx.user;
|
|
4671
|
+
const isAdmin = me?.role === "admin";
|
|
4672
|
+
const [row] = await sql.unsafe(`
|
|
4673
|
+
SELECT c.*, (SELECT u.name FROM "${self.usersTable}" u WHERE u.id = c.author_id) AS author_name
|
|
4674
|
+
FROM ${self.q("contents")} c
|
|
4675
|
+
WHERE c.slug = $1 ${isAdmin ? "" : "AND c.status = 'published'"}
|
|
4676
|
+
LIMIT 1
|
|
4677
|
+
`, [slug]);
|
|
4678
|
+
if (!row) return null;
|
|
4679
|
+
const content = toContent(row);
|
|
4680
|
+
const tagged = await attachTags([content]);
|
|
4681
|
+
return tagged[0];
|
|
4682
|
+
},
|
|
4683
|
+
async getById(id) {
|
|
4684
|
+
await self.ensureMigrated(sql);
|
|
4685
|
+
const [row] = await sql.unsafe(`
|
|
4686
|
+
SELECT c.*, (SELECT u.name FROM "${self.usersTable}" u WHERE u.id = c.author_id) AS author_name
|
|
4687
|
+
FROM ${self.q("contents")} c
|
|
4688
|
+
WHERE c.id = $1
|
|
4689
|
+
LIMIT 1
|
|
4690
|
+
`, [id]);
|
|
4691
|
+
if (!row) return null;
|
|
4692
|
+
const content = toContent(row);
|
|
4693
|
+
const tagged = await attachTags([content]);
|
|
4694
|
+
return tagged[0];
|
|
4695
|
+
},
|
|
4696
|
+
// ── Create ───────────────────────────────────────────
|
|
4697
|
+
async create(input) {
|
|
4698
|
+
requireAdmin(ctx);
|
|
4699
|
+
await self.ensureMigrated(sql);
|
|
4700
|
+
const slug = input.slug || slugify2(input.title);
|
|
4701
|
+
const status = input.status || "draft";
|
|
4702
|
+
const authorId = currentUserId2(ctx);
|
|
4703
|
+
const parentId = input.parent_id || null;
|
|
4704
|
+
const slugFinal = await self._uniqueSlug(sql, slug, input.type);
|
|
4705
|
+
const [row] = await sql.unsafe(`
|
|
4706
|
+
INSERT INTO ${self.q("contents")} (slug, type, parent_id, title, body, excerpt, cover_image, status, author_id)
|
|
4707
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
4708
|
+
RETURNING *
|
|
4709
|
+
`, [
|
|
4710
|
+
slugFinal,
|
|
4711
|
+
input.type,
|
|
4712
|
+
parentId,
|
|
4713
|
+
input.title,
|
|
4714
|
+
input.body,
|
|
4715
|
+
input.excerpt ?? null,
|
|
4716
|
+
input.cover_image ?? null,
|
|
4717
|
+
status,
|
|
4718
|
+
authorId
|
|
4719
|
+
]);
|
|
4720
|
+
const content = toContent(row);
|
|
4721
|
+
content.author_name = ctx.user ? ctx.user.name : void 0;
|
|
4722
|
+
if (input.tags && input.tags.length > 0) {
|
|
4723
|
+
await updateContentTags(content.id, input.tags);
|
|
4724
|
+
content.tags = await (async () => {
|
|
4725
|
+
const map = await fetchTags([content.id]);
|
|
4726
|
+
return map.get(content.id) || [];
|
|
4727
|
+
})();
|
|
4728
|
+
}
|
|
4729
|
+
if (status === "published") {
|
|
4730
|
+
await sql.unsafe(`UPDATE ${self.q("contents")} SET published_at = NOW() WHERE id = $1`, [content.id]);
|
|
4731
|
+
content.published_at = /* @__PURE__ */ new Date();
|
|
4732
|
+
}
|
|
4733
|
+
return content;
|
|
4734
|
+
},
|
|
4735
|
+
// ── Update ───────────────────────────────────────────
|
|
4736
|
+
async update(id, input) {
|
|
4737
|
+
requireAdmin(ctx);
|
|
4738
|
+
await self.ensureMigrated(sql);
|
|
4739
|
+
const sets = [];
|
|
4740
|
+
const values = [];
|
|
4741
|
+
let idx = 1;
|
|
4742
|
+
if (input.slug !== void 0) {
|
|
4743
|
+
sets.push(`slug = $${idx++}`);
|
|
4744
|
+
values.push(input.slug);
|
|
4745
|
+
}
|
|
4746
|
+
if (input.type !== void 0) {
|
|
4747
|
+
sets.push(`type = $${idx++}`);
|
|
4748
|
+
values.push(input.type);
|
|
4749
|
+
}
|
|
4750
|
+
if (input.title !== void 0) {
|
|
4751
|
+
sets.push(`title = $${idx++}`);
|
|
4752
|
+
values.push(input.title);
|
|
4753
|
+
}
|
|
4754
|
+
if (input.body !== void 0) {
|
|
4755
|
+
sets.push(`body = $${idx++}`);
|
|
4756
|
+
values.push(input.body);
|
|
4757
|
+
}
|
|
4758
|
+
if (input.excerpt !== void 0) {
|
|
4759
|
+
sets.push(`excerpt = $${idx++}`);
|
|
4760
|
+
values.push(input.excerpt);
|
|
4761
|
+
}
|
|
4762
|
+
if (input.cover_image !== void 0) {
|
|
4763
|
+
sets.push(`cover_image = $${idx++}`);
|
|
4764
|
+
values.push(input.cover_image);
|
|
4765
|
+
}
|
|
4766
|
+
if (input.status !== void 0) {
|
|
4767
|
+
sets.push(`status = $${idx++}`);
|
|
4768
|
+
values.push(input.status);
|
|
4769
|
+
}
|
|
4770
|
+
if (input.parent_id !== void 0) {
|
|
4771
|
+
sets.push(`parent_id = $${idx++}`);
|
|
4772
|
+
values.push(input.parent_id);
|
|
4773
|
+
}
|
|
4774
|
+
let row;
|
|
4775
|
+
if (sets.length > 0) {
|
|
4776
|
+
sets.push("updated_at = NOW()");
|
|
4777
|
+
values.push(id);
|
|
4778
|
+
[row] = await sql.unsafe(`
|
|
4779
|
+
UPDATE ${self.q("contents")} SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *
|
|
4780
|
+
`, values);
|
|
4781
|
+
if (!row) return null;
|
|
4782
|
+
} else {
|
|
4783
|
+
;
|
|
4784
|
+
[row] = await sql.unsafe(
|
|
4785
|
+
`SELECT * FROM ${self.q("contents")} WHERE id = $1 LIMIT 1`,
|
|
4786
|
+
[id]
|
|
4787
|
+
);
|
|
4788
|
+
if (!row) return null;
|
|
4789
|
+
if (input.tags !== void 0) {
|
|
4790
|
+
await sql.unsafe(`UPDATE ${self.q("contents")} SET updated_at = NOW() WHERE id = $1`, [id]);
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
if (input.tags !== void 0) {
|
|
4794
|
+
await updateContentTags(id, input.tags);
|
|
4795
|
+
}
|
|
4796
|
+
if (input.status === "published") {
|
|
4797
|
+
await sql.unsafe(`UPDATE ${self.q("contents")} SET published_at = COALESCE(published_at, NOW()) WHERE id = $1`, [id]);
|
|
4798
|
+
}
|
|
4799
|
+
const content = toContent(row);
|
|
4800
|
+
const tagged = await attachTags([content]);
|
|
4801
|
+
return tagged[0];
|
|
4802
|
+
},
|
|
4803
|
+
// ── Delete ───────────────────────────────────────────
|
|
4804
|
+
async delete(id) {
|
|
4805
|
+
requireAdmin(ctx);
|
|
4806
|
+
await self.ensureMigrated(sql);
|
|
4807
|
+
const [row] = await sql.unsafe(
|
|
4808
|
+
`DELETE FROM ${self.q("contents")} WHERE id = $1 RETURNING id`,
|
|
4809
|
+
[id]
|
|
4810
|
+
);
|
|
4811
|
+
return !!row;
|
|
4812
|
+
},
|
|
4813
|
+
// ── Publish / Unpublish ─────────────────────────────
|
|
4814
|
+
async publish(id) {
|
|
4815
|
+
requireAdmin(ctx);
|
|
4816
|
+
await self.ensureMigrated(sql);
|
|
4817
|
+
const [row] = await sql.unsafe(`
|
|
4818
|
+
UPDATE ${self.q("contents")}
|
|
4819
|
+
SET status = 'published', published_at = COALESCE(published_at, NOW()), updated_at = NOW()
|
|
4820
|
+
WHERE id = $1 RETURNING *
|
|
4821
|
+
`, [id]);
|
|
4822
|
+
if (!row) return null;
|
|
4823
|
+
const content = toContent(row);
|
|
4824
|
+
const tagged = await attachTags([content]);
|
|
4825
|
+
return tagged[0];
|
|
4826
|
+
},
|
|
4827
|
+
async unpublish(id) {
|
|
4828
|
+
requireAdmin(ctx);
|
|
4829
|
+
await self.ensureMigrated(sql);
|
|
4830
|
+
const [row] = await sql.unsafe(`
|
|
4831
|
+
UPDATE ${self.q("contents")}
|
|
4832
|
+
SET status = 'draft', updated_at = NOW()
|
|
4833
|
+
WHERE id = $1 RETURNING *
|
|
4834
|
+
`, [id]);
|
|
4835
|
+
if (!row) return null;
|
|
4836
|
+
const content = toContent(row);
|
|
4837
|
+
const tagged = await attachTags([content]);
|
|
4838
|
+
return tagged[0];
|
|
4839
|
+
},
|
|
4840
|
+
// ── Tags ─────────────────────────────────────────────
|
|
4841
|
+
async listTags() {
|
|
4842
|
+
await self.ensureMigrated(sql);
|
|
4843
|
+
const rows = await sql.unsafe(`
|
|
4844
|
+
SELECT t.*, COUNT(ct.content_id)::INT AS content_count
|
|
4845
|
+
FROM ${self.q("tags")} t
|
|
4846
|
+
LEFT JOIN ${self.q("contents_tags")} ct ON ct.tag_id = t.id
|
|
4847
|
+
GROUP BY t.id
|
|
4848
|
+
ORDER BY t.name
|
|
4849
|
+
`);
|
|
4850
|
+
return rows.map(toTagWithCount);
|
|
4851
|
+
},
|
|
4852
|
+
async createTag(name) {
|
|
4853
|
+
requireAdmin(ctx);
|
|
4854
|
+
await self.ensureMigrated(sql);
|
|
4855
|
+
const slug = slugify2(name);
|
|
4856
|
+
const [row] = await sql.unsafe(`
|
|
4857
|
+
INSERT INTO ${self.q("tags")} (name, slug) VALUES ($1, $2)
|
|
4858
|
+
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
|
4859
|
+
RETURNING *
|
|
4860
|
+
`, [name, slug]);
|
|
4861
|
+
return toTag(row);
|
|
4862
|
+
},
|
|
4863
|
+
async deleteTag(id) {
|
|
4864
|
+
requireAdmin(ctx);
|
|
4865
|
+
await self.ensureMigrated(sql);
|
|
4866
|
+
const [row] = await sql.unsafe(
|
|
4867
|
+
`DELETE FROM ${self.q("tags")} WHERE id = $1 RETURNING id`,
|
|
4868
|
+
[id]
|
|
4869
|
+
);
|
|
4870
|
+
return !!row;
|
|
4871
|
+
}
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4874
|
+
// ── Internal ──────────────────────────────────────────────
|
|
4875
|
+
async _uniqueSlug(sql, slug, type) {
|
|
4876
|
+
const [existing] = await sql.unsafe(
|
|
4877
|
+
`SELECT id FROM ${this.q("contents")} WHERE slug = $1 AND type = $2 LIMIT 1`,
|
|
4878
|
+
[slug, type]
|
|
4879
|
+
);
|
|
4880
|
+
if (!existing) return slug;
|
|
4881
|
+
for (let i = 2; i < 100; i++) {
|
|
4882
|
+
const candidate = `${slug}-${i}`;
|
|
4883
|
+
const [found] = await sql.unsafe(
|
|
4884
|
+
`SELECT id FROM ${this.q("contents")} WHERE slug = $1 AND type = $2 LIMIT 1`,
|
|
4885
|
+
[candidate, type]
|
|
4886
|
+
);
|
|
4887
|
+
if (!found) return candidate;
|
|
4888
|
+
}
|
|
4889
|
+
return `${slug}-${Date.now()}`;
|
|
4890
|
+
}
|
|
4891
|
+
// ── Middleware ─────────────────────────────────────────────
|
|
4892
|
+
async middleware(req, ctx, next) {
|
|
4893
|
+
ctx.cms = this.bind(ctx);
|
|
4894
|
+
return next(req, ctx);
|
|
4895
|
+
}
|
|
4896
|
+
};
|
|
4897
|
+
|
|
4898
|
+
// src/cms/index.ts
|
|
4899
|
+
function cms(opts) {
|
|
4900
|
+
const module = new CMS(opts);
|
|
4901
|
+
const mw = ((req, ctx, next) => {
|
|
4902
|
+
return module.middleware(req, ctx, next);
|
|
4903
|
+
});
|
|
4904
|
+
mw.__meta = { injects: ["cms"], depends: ["sql", "user"] };
|
|
4905
|
+
return mw;
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
// src/kb/module.ts
|
|
4909
|
+
var DEFAULT_DIMENSIONS = 1536;
|
|
4910
|
+
var DEFAULT_CHUNK_SIZE = 512;
|
|
4911
|
+
var DEFAULT_CHUNK_OVERLAP = 64;
|
|
4912
|
+
var DEFAULT_TOP_K = 5;
|
|
4913
|
+
async function dashscopeEmbed(text) {
|
|
4914
|
+
const apiKey = process.env.DASHSCOPE_API_KEY;
|
|
4915
|
+
if (!apiKey) throw new Error(
|
|
4916
|
+
"DASHSCOPE_API_KEY not set. Provide an embedding function in kb() options, or set the DASHSCOPE_API_KEY environment variable."
|
|
4917
|
+
);
|
|
4918
|
+
const baseURL = process.env.DASHSCOPE_API_URL || "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
4919
|
+
const res = await fetch(`${baseURL}/embeddings`, {
|
|
4920
|
+
method: "POST",
|
|
4921
|
+
headers: {
|
|
4922
|
+
"Content-Type": "application/json",
|
|
4923
|
+
"Authorization": `Bearer ${apiKey}`
|
|
4924
|
+
},
|
|
4925
|
+
body: JSON.stringify({
|
|
4926
|
+
model: "text-embedding-v4",
|
|
4927
|
+
input: text,
|
|
4928
|
+
dimensions: DEFAULT_DIMENSIONS
|
|
4929
|
+
})
|
|
4930
|
+
});
|
|
4931
|
+
if (!res.ok) {
|
|
4932
|
+
const body = await res.text();
|
|
4933
|
+
throw new Error(`DashScope embedding error (${res.status}): ${body}`);
|
|
4934
|
+
}
|
|
4935
|
+
const data = await res.json();
|
|
4936
|
+
return data.data[0].embedding;
|
|
4937
|
+
}
|
|
4938
|
+
function estimateTokens(text) {
|
|
4939
|
+
return Math.ceil(text.length / 3.5);
|
|
4940
|
+
}
|
|
4941
|
+
function splitIntoChunks(text, chunkSize, overlap) {
|
|
4942
|
+
const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
|
|
4943
|
+
const chunks = [];
|
|
4944
|
+
let current = "";
|
|
4945
|
+
for (const para of paragraphs) {
|
|
4946
|
+
if (estimateTokens(para) > chunkSize) {
|
|
4947
|
+
if (current) {
|
|
4948
|
+
chunks.push(current.trim());
|
|
4949
|
+
current = "";
|
|
4950
|
+
}
|
|
4951
|
+
const sentences = para.match(/[^.!?]+[.!?]+/g) || [para];
|
|
4952
|
+
let sub = "";
|
|
4953
|
+
for (const sent of sentences) {
|
|
4954
|
+
const combined2 = sub ? `${sub} ${sent}` : sent;
|
|
4955
|
+
if (estimateTokens(combined2) > chunkSize) {
|
|
4956
|
+
if (sub) chunks.push(sub.trim());
|
|
4957
|
+
sub = sent;
|
|
4958
|
+
} else {
|
|
4959
|
+
sub = combined2;
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4962
|
+
if (sub) current = sub;
|
|
4963
|
+
continue;
|
|
4964
|
+
}
|
|
4965
|
+
const combined = current ? `${current}
|
|
4966
|
+
|
|
4967
|
+
${para}` : para;
|
|
4968
|
+
if (estimateTokens(combined) > chunkSize) {
|
|
4969
|
+
chunks.push(current.trim());
|
|
4970
|
+
current = para;
|
|
4971
|
+
} else {
|
|
4972
|
+
current = combined;
|
|
4973
|
+
}
|
|
4974
|
+
}
|
|
4975
|
+
if (current.trim()) {
|
|
4976
|
+
chunks.push(current.trim());
|
|
4977
|
+
}
|
|
4978
|
+
if (overlap > 0 && chunks.length > 1) {
|
|
4979
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
4980
|
+
const prev = chunks[i - 1];
|
|
4981
|
+
const prevWords = prev.split(/\s+/);
|
|
4982
|
+
const overlapWords = [];
|
|
4983
|
+
let overlapTokens = 0;
|
|
4984
|
+
for (let j = prevWords.length - 1; j >= 0 && overlapTokens < overlap; j--) {
|
|
4985
|
+
overlapWords.unshift(prevWords[j]);
|
|
4986
|
+
overlapTokens += Math.ceil(prevWords[j].length / 3.5);
|
|
4987
|
+
}
|
|
4988
|
+
if (overlapWords.length > 0) {
|
|
4989
|
+
chunks[i] = overlapWords.join(" ") + "\n\n" + chunks[i];
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
return chunks;
|
|
4994
|
+
}
|
|
4995
|
+
function getSql4(ctx) {
|
|
4996
|
+
const sql = ctx.sql;
|
|
4997
|
+
if (!sql) throw new Error("kb() requires postgres() middleware");
|
|
4998
|
+
return sql;
|
|
4999
|
+
}
|
|
5000
|
+
function toDocument(row) {
|
|
5001
|
+
return {
|
|
5002
|
+
id: row.id,
|
|
5003
|
+
title: row.title,
|
|
5004
|
+
source: row.source,
|
|
5005
|
+
metadata: row.metadata || {},
|
|
5006
|
+
chunk_count: row.chunk_count,
|
|
5007
|
+
created_at: row.created_at,
|
|
5008
|
+
updated_at: row.updated_at
|
|
5009
|
+
};
|
|
5010
|
+
}
|
|
5011
|
+
function toChunk(row) {
|
|
5012
|
+
return {
|
|
5013
|
+
id: row.id,
|
|
5014
|
+
document_id: row.document_id,
|
|
5015
|
+
content: row.content,
|
|
5016
|
+
chunk_index: row.chunk_index,
|
|
5017
|
+
tokens: row.tokens,
|
|
5018
|
+
created_at: row.created_at
|
|
5019
|
+
};
|
|
5020
|
+
}
|
|
5021
|
+
var KB = class {
|
|
5022
|
+
migrated = false;
|
|
5023
|
+
embedFn;
|
|
5024
|
+
dimensions;
|
|
5025
|
+
chunkSize;
|
|
5026
|
+
chunkOverlap;
|
|
5027
|
+
constructor(opts) {
|
|
5028
|
+
this.embedFn = opts?.embed ?? dashscopeEmbed;
|
|
5029
|
+
this.dimensions = opts?.dimensions ?? DEFAULT_DIMENSIONS;
|
|
5030
|
+
this.chunkSize = opts?.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
5031
|
+
this.chunkOverlap = opts?.chunkOverlap ?? DEFAULT_CHUNK_OVERLAP;
|
|
5032
|
+
}
|
|
5033
|
+
ms(name) {
|
|
5034
|
+
return `"public"."${name}"`;
|
|
5035
|
+
}
|
|
5036
|
+
// ── Migration ──────────────────────────────────────────────
|
|
5037
|
+
async migrate(sql) {
|
|
5038
|
+
if (this.migrated) return;
|
|
5039
|
+
await sql.unsafe("CREATE EXTENSION IF NOT EXISTS vector");
|
|
5040
|
+
await sql.unsafe(`
|
|
5041
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("kb_documents")} (
|
|
5042
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
5043
|
+
title TEXT NOT NULL,
|
|
5044
|
+
source TEXT,
|
|
5045
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
5046
|
+
chunk_count INT NOT NULL DEFAULT 0,
|
|
5047
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
5048
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
5049
|
+
)
|
|
5050
|
+
`);
|
|
5051
|
+
await sql.unsafe(`
|
|
5052
|
+
CREATE TABLE IF NOT EXISTS ${this.ms("kb_chunks")} (
|
|
5053
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
5054
|
+
document_id UUID REFERENCES ${this.ms("kb_documents")}(id) ON DELETE CASCADE,
|
|
5055
|
+
content TEXT NOT NULL,
|
|
5056
|
+
chunk_index INT NOT NULL,
|
|
5057
|
+
embedding VECTOR(${this.dimensions}) NOT NULL,
|
|
5058
|
+
tokens INT NOT NULL DEFAULT 0,
|
|
5059
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
5060
|
+
)
|
|
5061
|
+
`);
|
|
5062
|
+
await sql.unsafe(`
|
|
5063
|
+
CREATE INDEX IF NOT EXISTS kb_chunks_doc_idx
|
|
5064
|
+
ON ${this.ms("kb_chunks")} (document_id)
|
|
5065
|
+
`);
|
|
5066
|
+
await sql.unsafe(`
|
|
5067
|
+
CREATE INDEX IF NOT EXISTS kb_chunks_content_idx
|
|
5068
|
+
ON ${this.ms("kb_chunks")} USING GIN (to_tsvector('simple', content))
|
|
5069
|
+
`);
|
|
5070
|
+
this.migrated = true;
|
|
5071
|
+
}
|
|
5072
|
+
async ensureMigrated(sql) {
|
|
5073
|
+
if (!this.migrated) await this.migrate(sql);
|
|
5074
|
+
}
|
|
5075
|
+
// ── Per-request bound API ──────────────────────────────────
|
|
5076
|
+
bind(ctx) {
|
|
5077
|
+
const self = this;
|
|
5078
|
+
const sql = getSql4(ctx);
|
|
5079
|
+
if (!this.migrated) {
|
|
5080
|
+
this.migrate(sql).catch(() => {
|
|
5081
|
+
});
|
|
5082
|
+
}
|
|
5083
|
+
return {
|
|
5084
|
+
// ── Import text ─────────────────────────────────────
|
|
5085
|
+
async importText(title, text, opts) {
|
|
5086
|
+
await self.ensureMigrated(sql);
|
|
5087
|
+
const chunkSize = opts?.chunkSize ?? self.chunkSize;
|
|
5088
|
+
const overlap = opts?.chunkOverlap ?? self.chunkOverlap;
|
|
5089
|
+
const chunks = splitIntoChunks(text, chunkSize, overlap);
|
|
5090
|
+
if (chunks.length === 0) throw new Error("No content to import");
|
|
5091
|
+
const meta = opts?.metadata ?? {};
|
|
5092
|
+
const [docRow] = await sql.unsafe(
|
|
5093
|
+
`
|
|
5094
|
+
INSERT INTO ${self.ms("kb_documents")} (title, source, metadata)
|
|
5095
|
+
VALUES ($1, $2, $3) RETURNING *
|
|
5096
|
+
`,
|
|
5097
|
+
[title, opts?.source ?? null, meta]
|
|
5098
|
+
);
|
|
5099
|
+
const chunkRows = [];
|
|
5100
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
5101
|
+
const content = chunks[i];
|
|
5102
|
+
const tokens = estimateTokens(content);
|
|
5103
|
+
const [chunkRow] = await sql.unsafe(`
|
|
5104
|
+
INSERT INTO ${self.ms("kb_chunks")} (document_id, content, chunk_index, embedding, tokens)
|
|
5105
|
+
VALUES ($1, $2, $3, $4::vector, $5) RETURNING *
|
|
5106
|
+
`, [
|
|
5107
|
+
docRow.id,
|
|
5108
|
+
content,
|
|
5109
|
+
i,
|
|
5110
|
+
JSON.stringify(await self.embedFn(content)),
|
|
5111
|
+
tokens
|
|
5112
|
+
]);
|
|
5113
|
+
chunkRows.push(toChunk(chunkRow));
|
|
5114
|
+
}
|
|
5115
|
+
await sql.unsafe(
|
|
5116
|
+
`UPDATE ${self.ms("kb_documents")} SET chunk_count = $1 WHERE id = $2`,
|
|
5117
|
+
[chunks.length, docRow.id]
|
|
5118
|
+
);
|
|
5119
|
+
const doc = toDocument(docRow);
|
|
5120
|
+
doc.chunk_count = chunks.length;
|
|
5121
|
+
return { document: doc, chunks: chunkRows };
|
|
5122
|
+
},
|
|
5123
|
+
// ── Import documents ─────────────────────────────────
|
|
5124
|
+
async importDocuments(docs) {
|
|
5125
|
+
const results = [];
|
|
5126
|
+
for (const doc of docs) {
|
|
5127
|
+
const { document } = await this.importText(doc.title, doc.content, {
|
|
5128
|
+
source: doc.source,
|
|
5129
|
+
metadata: doc.metadata,
|
|
5130
|
+
chunkSize: doc.chunkSize,
|
|
5131
|
+
chunkOverlap: doc.chunkOverlap
|
|
5132
|
+
});
|
|
5133
|
+
results.push(document);
|
|
5134
|
+
}
|
|
5135
|
+
return results;
|
|
5136
|
+
},
|
|
5137
|
+
// ── Search ─────────────────────────────────────────
|
|
5138
|
+
async search(query, opts) {
|
|
5139
|
+
await self.ensureMigrated(sql);
|
|
5140
|
+
const limit = Math.min(opts?.limit ?? DEFAULT_TOP_K, 50);
|
|
5141
|
+
const minScore = opts?.minScore ?? 0;
|
|
5142
|
+
const queryVec = await self.embedFn(query);
|
|
5143
|
+
const filterJoin = opts?.filter ? Object.entries(opts.filter).map(
|
|
5144
|
+
([k, v]) => `AND d.metadata @> '${JSON.stringify({ [k]: v })}'::jsonb`
|
|
5145
|
+
).join(" ") : "";
|
|
5146
|
+
const rows = await sql.unsafe(`
|
|
5147
|
+
SELECT
|
|
5148
|
+
c.id AS chunk_id,
|
|
5149
|
+
c.document_id,
|
|
5150
|
+
c.content,
|
|
5151
|
+
1 - (c.embedding <=> $1::vector) AS score,
|
|
5152
|
+
d.title,
|
|
5153
|
+
d.source
|
|
5154
|
+
FROM ${self.ms("kb_chunks")} c
|
|
5155
|
+
LEFT JOIN ${self.ms("kb_documents")} d ON d.id = c.document_id
|
|
5156
|
+
WHERE (1 - (c.embedding <=> $1::vector)) >= $2
|
|
5157
|
+
${filterJoin}
|
|
5158
|
+
ORDER BY score DESC
|
|
5159
|
+
LIMIT $3
|
|
5160
|
+
`, [JSON.stringify(queryVec), minScore, limit]);
|
|
5161
|
+
return rows.map((r) => ({
|
|
5162
|
+
chunk_id: r.chunk_id,
|
|
5163
|
+
document_id: r.document_id,
|
|
5164
|
+
content: r.content,
|
|
5165
|
+
score: r.score,
|
|
5166
|
+
title: r.title,
|
|
5167
|
+
source: r.source
|
|
5168
|
+
}));
|
|
5169
|
+
},
|
|
5170
|
+
// ── List documents ───────────────────────────────────
|
|
5171
|
+
async list() {
|
|
5172
|
+
await self.ensureMigrated(sql);
|
|
5173
|
+
const rows = await sql.unsafe(
|
|
5174
|
+
`SELECT * FROM ${self.ms("kb_documents")} ORDER BY created_at DESC`
|
|
5175
|
+
);
|
|
5176
|
+
return rows.map(toDocument);
|
|
5177
|
+
},
|
|
5178
|
+
// ── Get document ─────────────────────────────────────
|
|
5179
|
+
async get(id) {
|
|
5180
|
+
await self.ensureMigrated(sql);
|
|
5181
|
+
const [row] = await sql.unsafe(
|
|
5182
|
+
`SELECT * FROM ${self.ms("kb_documents")} WHERE id = $1 LIMIT 1`,
|
|
5183
|
+
[id]
|
|
5184
|
+
);
|
|
5185
|
+
return row ? toDocument(row) : null;
|
|
5186
|
+
},
|
|
5187
|
+
// ── Get chunks ───────────────────────────────────────
|
|
5188
|
+
async getChunks(documentId) {
|
|
5189
|
+
await self.ensureMigrated(sql);
|
|
5190
|
+
const rows = await sql.unsafe(
|
|
5191
|
+
`SELECT * FROM ${self.ms("kb_chunks")} WHERE document_id = $1 ORDER BY chunk_index`,
|
|
5192
|
+
[documentId]
|
|
5193
|
+
);
|
|
5194
|
+
return rows.map(toChunk);
|
|
5195
|
+
},
|
|
5196
|
+
// ── Delete document ──────────────────────────────────
|
|
5197
|
+
async delete(id) {
|
|
5198
|
+
await self.ensureMigrated(sql);
|
|
5199
|
+
const [row] = await sql.unsafe(
|
|
5200
|
+
`DELETE FROM ${self.ms("kb_documents")} WHERE id = $1 RETURNING id`,
|
|
5201
|
+
[id]
|
|
5202
|
+
);
|
|
5203
|
+
return !!row;
|
|
5204
|
+
}
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5207
|
+
// ── Middleware ─────────────────────────────────────────────
|
|
5208
|
+
async middleware(req, ctx, next) {
|
|
5209
|
+
ctx.kb = this.bind(ctx);
|
|
5210
|
+
return next(req, ctx);
|
|
5211
|
+
}
|
|
5212
|
+
};
|
|
5213
|
+
|
|
5214
|
+
// src/kb/index.ts
|
|
5215
|
+
function kb(opts) {
|
|
5216
|
+
const module = new KB(opts);
|
|
5217
|
+
const mw = ((req, ctx, next) => {
|
|
5218
|
+
return module.middleware(req, ctx, next);
|
|
5219
|
+
});
|
|
5220
|
+
mw.__meta = { injects: ["kb"], depends: ["sql"] };
|
|
5221
|
+
return mw;
|
|
5222
|
+
}
|
|
3080
5223
|
export {
|
|
5224
|
+
Base,
|
|
5225
|
+
CMS,
|
|
3081
5226
|
DEFAULT_MAX_BODY,
|
|
3082
5227
|
ErrorBoundary,
|
|
3083
5228
|
HttpError,
|
|
5229
|
+
KB,
|
|
3084
5230
|
Link,
|
|
3085
5231
|
MIGRATIONS_TABLE,
|
|
5232
|
+
Messager,
|
|
3086
5233
|
Router,
|
|
3087
5234
|
ServerDataContext,
|
|
5235
|
+
UserModule,
|
|
3088
5236
|
agent,
|
|
3089
5237
|
ai,
|
|
3090
|
-
|
|
5238
|
+
base,
|
|
5239
|
+
cms,
|
|
3091
5240
|
compress,
|
|
3092
5241
|
cors,
|
|
3093
5242
|
createHub,
|
|
@@ -3096,13 +5245,16 @@ export {
|
|
|
3096
5245
|
esbuildDev,
|
|
3097
5246
|
graphql,
|
|
3098
5247
|
helmet,
|
|
5248
|
+
kb,
|
|
3099
5249
|
logger,
|
|
5250
|
+
messager,
|
|
3100
5251
|
postgres,
|
|
3101
5252
|
queue,
|
|
3102
5253
|
rateLimit,
|
|
3103
5254
|
react,
|
|
3104
5255
|
reactRouter,
|
|
3105
5256
|
redis,
|
|
5257
|
+
requireRole,
|
|
3106
5258
|
runWithTrace,
|
|
3107
5259
|
sandbox,
|
|
3108
5260
|
serve,
|
|
@@ -3111,5 +5263,6 @@ export {
|
|
|
3111
5263
|
trace,
|
|
3112
5264
|
traceElapsed,
|
|
3113
5265
|
upload,
|
|
3114
|
-
useServerData
|
|
5266
|
+
useServerData,
|
|
5267
|
+
user
|
|
3115
5268
|
};
|