@chatu-ai/app-sdk 0.7.9 → 0.8.1
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 +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/db.d.ts +90 -0
- package/dist/db.js +321 -0
- package/dist/db.test.d.ts +1 -0
- package/dist/db.test.js +150 -0
- package/dist/edgeone.d.ts +6 -0
- package/dist/edgeone.js +129 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/skills/chatu-db/SKILL.md +153 -0
package/README.md
CHANGED
|
@@ -71,6 +71,6 @@ Never expose `CHATU_APP_KEY` to the browser. MIT.
|
|
|
71
71
|
|
|
72
72
|
## Agent skills
|
|
73
73
|
|
|
74
|
-
The package ships `skills/chatu-{kv,storage,ai}/SKILL.md` — task-focused manuals for coding agents
|
|
74
|
+
The package ships `skills/chatu-{kv,db,storage,ai}/SKILL.md` — task-focused manuals for coding agents
|
|
75
75
|
(standard rules, boilerplate, boundaries, common failure modes). The ChatU Builder sandbox copies them
|
|
76
76
|
into the workspace `.claude/skills/` so Claude Code loads them on demand; they are versioned with the SDK.
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -81,7 +81,7 @@ export function describe() {
|
|
|
81
81
|
if (c.kind === 'byo')
|
|
82
82
|
return { driver: 'byo', kv: c.redisUrl ? 'redis' : 'memory', storage: c.s3 ? 's3' : 'memory' };
|
|
83
83
|
if (c.kind === 'edgeone')
|
|
84
|
-
return { driver: 'edgeone', kv: `blob:${c.kvStore}`, storage: `blob:${c.storageStore}` };
|
|
84
|
+
return { driver: 'edgeone', kv: `blob:${c.kvStore}`, storage: `blob:${c.storageStore}`, db: `blob:${c.kvStore}/db` };
|
|
85
85
|
return { driver: 'memory' };
|
|
86
86
|
}
|
|
87
87
|
/**
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文档集合(技术方案 19):比 kv 更适合"列表 + 条件查询 + 排序分页"的业务数据。
|
|
3
|
+
* 平台托管(platform)走 Data API `/data/v1/db/*`;edgeone 用 Pages Blob 每文档一个对象;memory 为本地降级。
|
|
4
|
+
* 只能在服务端使用。
|
|
5
|
+
*/
|
|
6
|
+
/** 文档:应用自定义字段 + 平台补充的 _id/_createdAt/_updatedAt(毫秒时间戳) */
|
|
7
|
+
export type Doc<T> = T & {
|
|
8
|
+
_id: string;
|
|
9
|
+
_createdAt: number;
|
|
10
|
+
_updatedAt: number;
|
|
11
|
+
};
|
|
12
|
+
export type FilterOp<V = unknown> = {
|
|
13
|
+
$gt?: V;
|
|
14
|
+
$gte?: V;
|
|
15
|
+
$lt?: V;
|
|
16
|
+
$lte?: V;
|
|
17
|
+
$ne?: V;
|
|
18
|
+
$in?: V[];
|
|
19
|
+
$nin?: V[];
|
|
20
|
+
/** 字符串包含(不区分大小写);数组字段则表示"包含某元素" */
|
|
21
|
+
$contains?: V extends Array<infer E> ? E : V;
|
|
22
|
+
$exists?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** 过滤:{字段: 值} 等值;{字段: {$gt: 1}} 操作符;$and/$or/$not 组合;字段支持 a.b 点路径 */
|
|
25
|
+
export type Filter<T = Record<string, unknown>> = ({
|
|
26
|
+
[K in keyof T]?: T[K] | FilterOp<T[K]>;
|
|
27
|
+
} & {
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}) | {
|
|
30
|
+
$and?: Filter<T>[];
|
|
31
|
+
$or?: Filter<T>[];
|
|
32
|
+
$not?: Filter<T>;
|
|
33
|
+
};
|
|
34
|
+
export type Sort = Record<string, 1 | -1>;
|
|
35
|
+
export interface FindOptions<T = Record<string, unknown>> {
|
|
36
|
+
filter?: Filter<T>;
|
|
37
|
+
sort?: Sort;
|
|
38
|
+
skip?: number;
|
|
39
|
+
/** 单页上限 200,默认 50 */
|
|
40
|
+
limit?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface FindResult<T> {
|
|
43
|
+
docs: Doc<T>[];
|
|
44
|
+
/** 满足 filter 的总数 */
|
|
45
|
+
total: number;
|
|
46
|
+
/** 还有下一页时为下一次的 skip,否则 null */
|
|
47
|
+
nextSkip: number | null;
|
|
48
|
+
}
|
|
49
|
+
export interface UpdateInput<T> {
|
|
50
|
+
set?: Partial<T> & Record<string, unknown>;
|
|
51
|
+
unset?: string[];
|
|
52
|
+
/** 数值字段增减:{ views: 1 } */
|
|
53
|
+
inc?: Record<string, number>;
|
|
54
|
+
/** 不存在时创建(默认 false) */
|
|
55
|
+
upsert?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface Collection<T = Record<string, unknown>> {
|
|
58
|
+
insert(doc: Partial<T> & Record<string, unknown>): Promise<Doc<T>>;
|
|
59
|
+
insertMany(docs: Array<Partial<T> & Record<string, unknown>>): Promise<string[]>;
|
|
60
|
+
get(id: string): Promise<Doc<T> | null>;
|
|
61
|
+
find(options?: FindOptions<T>): Promise<FindResult<T>>;
|
|
62
|
+
/** 取第一条匹配(等价 find({filter, limit:1}).docs[0]) */
|
|
63
|
+
findOne(filter?: Filter<T>, options?: Omit<FindOptions<T>, 'filter' | 'limit'>): Promise<Doc<T> | null>;
|
|
64
|
+
count(filter?: Filter<T>): Promise<number>;
|
|
65
|
+
update(id: string, input: UpdateInput<T>): Promise<Doc<T> | null>;
|
|
66
|
+
replace(id: string, doc: Partial<T> & Record<string, unknown>): Promise<Doc<T>>;
|
|
67
|
+
delete(id: string): Promise<boolean>;
|
|
68
|
+
deleteMany(filter?: Filter<T>): Promise<number>;
|
|
69
|
+
/** 清空集合 */
|
|
70
|
+
drop(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export interface DbClient {
|
|
73
|
+
collection<T = Record<string, unknown>>(name: string): Collection<T>;
|
|
74
|
+
collections(): Promise<Array<{
|
|
75
|
+
name: string;
|
|
76
|
+
count: number;
|
|
77
|
+
}>>;
|
|
78
|
+
}
|
|
79
|
+
export declare function matchesFilter(doc: unknown, filter: unknown): boolean;
|
|
80
|
+
export declare function applySort<T>(docs: T[], sort?: Sort): T[];
|
|
81
|
+
/** 时间有序 id(与服务端同格式) */
|
|
82
|
+
export declare function newDocId(): string;
|
|
83
|
+
export declare function withMeta<T>(doc: Record<string, unknown>, id: string, createdAt: number, updatedAt: number): Doc<T>;
|
|
84
|
+
/** 在一组内存文档上执行 find(memory / edgeone 驱动共用) */
|
|
85
|
+
export declare function queryDocs<T>(all: Doc<T>[], options?: FindOptions<T>): FindResult<T>;
|
|
86
|
+
/** 对已有文档应用 set/unset/inc */
|
|
87
|
+
export declare function applyUpdate<T>(current: Doc<T>, input: UpdateInput<T>): Doc<T>;
|
|
88
|
+
export declare function getDb(): DbClient;
|
|
89
|
+
/** 便捷单例:`import { db } from '@chatu-ai/app-sdk'` */
|
|
90
|
+
export declare const db: DbClient;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { resolveConfig } from './config.js';
|
|
2
|
+
import { AppSdkError } from './errors.js';
|
|
3
|
+
import { edgeoneDb } from './edgeone.js';
|
|
4
|
+
// ---------- platform driver ----------
|
|
5
|
+
function platformDb(cfg) {
|
|
6
|
+
const headers = { 'x-api-key': cfg.apiKey, 'x-chatu-env': cfg.env, 'content-type': 'application/json' };
|
|
7
|
+
async function call(method, path, body) {
|
|
8
|
+
const res = await cfg.fetchImpl(`${cfg.baseUrl}/db${path}`, {
|
|
9
|
+
method,
|
|
10
|
+
headers,
|
|
11
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
12
|
+
});
|
|
13
|
+
let json = null;
|
|
14
|
+
try {
|
|
15
|
+
json = await res.json();
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignore */ }
|
|
18
|
+
if (!res.ok || json?.ok === false) {
|
|
19
|
+
if (res.status === 404 && json?.error === 'NOT_FOUND')
|
|
20
|
+
return null;
|
|
21
|
+
throw new AppSdkError(json?.error ?? `HTTP_${res.status}`, json?.message ?? `db ${method} ${path} failed (${res.status})`, res.status);
|
|
22
|
+
}
|
|
23
|
+
return json;
|
|
24
|
+
}
|
|
25
|
+
const enc = (s) => encodeURIComponent(s);
|
|
26
|
+
return {
|
|
27
|
+
collections: async () => (await call('GET', '')).collections,
|
|
28
|
+
collection(name) {
|
|
29
|
+
const base = `/${enc(name)}`;
|
|
30
|
+
return {
|
|
31
|
+
async insert(doc) {
|
|
32
|
+
const r = await call('POST', base, { doc });
|
|
33
|
+
const saved = await this.get(r.ids[0]);
|
|
34
|
+
if (!saved)
|
|
35
|
+
throw new AppSdkError('INSERT_FAILED', 'inserted doc not found');
|
|
36
|
+
return saved;
|
|
37
|
+
},
|
|
38
|
+
async insertMany(docs) {
|
|
39
|
+
if (docs.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
return (await call('POST', base, { docs })).ids;
|
|
42
|
+
},
|
|
43
|
+
async get(id) {
|
|
44
|
+
const r = await call('GET', `${base}/${enc(id)}`);
|
|
45
|
+
return r?.exists ? r.doc : null;
|
|
46
|
+
},
|
|
47
|
+
async find(options) {
|
|
48
|
+
const r = await call('POST', `${base}/query`, {
|
|
49
|
+
filter: options?.filter, sort: options?.sort, skip: options?.skip, limit: options?.limit,
|
|
50
|
+
});
|
|
51
|
+
return { docs: r.docs, total: r.total, nextSkip: r.nextSkip ?? null };
|
|
52
|
+
},
|
|
53
|
+
async findOne(filter, options) {
|
|
54
|
+
const r = await this.find({ ...options, filter, limit: 1 });
|
|
55
|
+
return r.docs[0] ?? null;
|
|
56
|
+
},
|
|
57
|
+
async count(filter) {
|
|
58
|
+
const q = filter ? `?filter=${encodeURIComponent(JSON.stringify(filter))}` : '';
|
|
59
|
+
return (await call('GET', `${base}/count${q}`)).count;
|
|
60
|
+
},
|
|
61
|
+
async update(id, input) {
|
|
62
|
+
const r = await call('PATCH', `${base}/${enc(id)}`, input);
|
|
63
|
+
return r?.doc ?? null;
|
|
64
|
+
},
|
|
65
|
+
async replace(id, doc) {
|
|
66
|
+
return (await call('PUT', `${base}/${enc(id)}`, doc)).doc;
|
|
67
|
+
},
|
|
68
|
+
async delete(id) {
|
|
69
|
+
return (await call('DELETE', `${base}/${enc(id)}`)).removed;
|
|
70
|
+
},
|
|
71
|
+
async deleteMany(filter) {
|
|
72
|
+
return (await call('POST', `${base}/delete-many`, { filter: filter ?? {} })).removed;
|
|
73
|
+
},
|
|
74
|
+
async drop() { await call('DELETE', base); },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// ---------- 共享:内存过滤 / 排序(memory 与 edgeone 驱动复用) ----------
|
|
80
|
+
function resolvePath(doc, path) {
|
|
81
|
+
let cur = doc;
|
|
82
|
+
for (const seg of path.split('.')) {
|
|
83
|
+
if (cur === null || typeof cur !== 'object' || !(seg in cur))
|
|
84
|
+
return undefined;
|
|
85
|
+
cur = cur[seg];
|
|
86
|
+
}
|
|
87
|
+
return cur;
|
|
88
|
+
}
|
|
89
|
+
function eq(a, b) {
|
|
90
|
+
if (a === b)
|
|
91
|
+
return true;
|
|
92
|
+
if (typeof a === 'object' && typeof b === 'object' && a && b)
|
|
93
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function cmp(a, b) {
|
|
97
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
98
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
99
|
+
if (typeof a === 'string' && typeof b === 'string')
|
|
100
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
export function matchesFilter(doc, filter) {
|
|
104
|
+
if (!filter || typeof filter !== 'object')
|
|
105
|
+
return true;
|
|
106
|
+
for (const [key, expected] of Object.entries(filter)) {
|
|
107
|
+
if (key === '$and') {
|
|
108
|
+
if (!Array.isArray(expected) || !expected.every(f => matchesFilter(doc, f)))
|
|
109
|
+
return false;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (key === '$or') {
|
|
113
|
+
if (!Array.isArray(expected) || !expected.some(f => matchesFilter(doc, f)))
|
|
114
|
+
return false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (key === '$not') {
|
|
118
|
+
if (matchesFilter(doc, expected))
|
|
119
|
+
return false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const actual = resolvePath(doc, key);
|
|
123
|
+
if (expected && typeof expected === 'object' && !Array.isArray(expected) && Object.keys(expected).some(k => k.startsWith('$'))) {
|
|
124
|
+
for (const [op, v] of Object.entries(expected)) {
|
|
125
|
+
switch (op) {
|
|
126
|
+
case '$exists':
|
|
127
|
+
if ((actual !== undefined) !== !!v)
|
|
128
|
+
return false;
|
|
129
|
+
break;
|
|
130
|
+
case '$ne':
|
|
131
|
+
if (eq(actual, v))
|
|
132
|
+
return false;
|
|
133
|
+
break;
|
|
134
|
+
case '$in':
|
|
135
|
+
if (!Array.isArray(v) || !v.some(x => eq(actual, x)))
|
|
136
|
+
return false;
|
|
137
|
+
break;
|
|
138
|
+
case '$nin':
|
|
139
|
+
if (Array.isArray(v) && v.some(x => eq(actual, x)))
|
|
140
|
+
return false;
|
|
141
|
+
break;
|
|
142
|
+
case '$gt':
|
|
143
|
+
case '$gte':
|
|
144
|
+
case '$lt':
|
|
145
|
+
case '$lte': {
|
|
146
|
+
const c = cmp(actual, v);
|
|
147
|
+
if (c === null)
|
|
148
|
+
return false;
|
|
149
|
+
if (op === '$gt' && !(c > 0))
|
|
150
|
+
return false;
|
|
151
|
+
if (op === '$gte' && !(c >= 0))
|
|
152
|
+
return false;
|
|
153
|
+
if (op === '$lt' && !(c < 0))
|
|
154
|
+
return false;
|
|
155
|
+
if (op === '$lte' && !(c <= 0))
|
|
156
|
+
return false;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case '$contains':
|
|
160
|
+
if (typeof actual === 'string') {
|
|
161
|
+
if (!actual.toLowerCase().includes(String(v).toLowerCase()))
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
else if (Array.isArray(actual)) {
|
|
165
|
+
if (!actual.some(x => eq(x, v)))
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
return false;
|
|
170
|
+
break;
|
|
171
|
+
default: return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!eq(actual, expected))
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
export function applySort(docs, sort) {
|
|
182
|
+
if (!sort || Object.keys(sort).length === 0)
|
|
183
|
+
return docs;
|
|
184
|
+
const keys = Object.entries(sort);
|
|
185
|
+
return [...docs].sort((x, y) => {
|
|
186
|
+
for (const [field, dir] of keys) {
|
|
187
|
+
const a = resolvePath(x, field);
|
|
188
|
+
const b = resolvePath(y, field);
|
|
189
|
+
let c;
|
|
190
|
+
if (a === undefined && b === undefined)
|
|
191
|
+
c = 0;
|
|
192
|
+
else if (a === undefined)
|
|
193
|
+
c = -1;
|
|
194
|
+
else if (b === undefined)
|
|
195
|
+
c = 1;
|
|
196
|
+
else
|
|
197
|
+
c = cmp(a, b) ?? (JSON.stringify(a) < JSON.stringify(b) ? -1 : JSON.stringify(a) === JSON.stringify(b) ? 0 : 1);
|
|
198
|
+
if (c !== 0)
|
|
199
|
+
return dir < 0 ? -c : c;
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** 时间有序 id(与服务端同格式) */
|
|
205
|
+
export function newDocId() {
|
|
206
|
+
return Date.now().toString(36).padStart(9, '0') + Math.random().toString(36).slice(2, 10);
|
|
207
|
+
}
|
|
208
|
+
export function withMeta(doc, id, createdAt, updatedAt) {
|
|
209
|
+
const { _id: _i, _createdAt: _c, _updatedAt: _u, ...rest } = doc;
|
|
210
|
+
return { _id: id, _createdAt: createdAt, _updatedAt: updatedAt, ...rest };
|
|
211
|
+
}
|
|
212
|
+
/** 在一组内存文档上执行 find(memory / edgeone 驱动共用) */
|
|
213
|
+
export function queryDocs(all, options) {
|
|
214
|
+
const matched = all.filter(d => matchesFilter(d, options?.filter));
|
|
215
|
+
const sorted = applySort(matched, options?.sort);
|
|
216
|
+
const skip = Math.max(0, options?.skip ?? 0);
|
|
217
|
+
const limit = Math.min(Math.max(1, options?.limit ?? 50), 200);
|
|
218
|
+
const page = sorted.slice(skip, skip + limit);
|
|
219
|
+
return { docs: page, total: sorted.length, nextSkip: skip + page.length < sorted.length ? skip + page.length : null };
|
|
220
|
+
}
|
|
221
|
+
/** 对已有文档应用 set/unset/inc */
|
|
222
|
+
export function applyUpdate(current, input) {
|
|
223
|
+
const next = { ...current };
|
|
224
|
+
if (input.set)
|
|
225
|
+
Object.assign(next, input.set);
|
|
226
|
+
for (const f of input.unset ?? [])
|
|
227
|
+
delete next[f];
|
|
228
|
+
for (const [f, delta] of Object.entries(input.inc ?? {})) {
|
|
229
|
+
const cur = typeof next[f] === 'number' ? next[f] : 0;
|
|
230
|
+
next[f] = cur + delta;
|
|
231
|
+
}
|
|
232
|
+
return withMeta(next, current._id, current._createdAt, Date.now());
|
|
233
|
+
}
|
|
234
|
+
// ---------- memory driver ----------
|
|
235
|
+
function memoryDb() {
|
|
236
|
+
const store = new Map();
|
|
237
|
+
const of = (name) => {
|
|
238
|
+
let m = store.get(name);
|
|
239
|
+
if (!m) {
|
|
240
|
+
m = new Map();
|
|
241
|
+
store.set(name, m);
|
|
242
|
+
}
|
|
243
|
+
return m;
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
async collections() {
|
|
247
|
+
return [...store.entries()].filter(([, m]) => m.size > 0).map(([name, m]) => ({ name, count: m.size }));
|
|
248
|
+
},
|
|
249
|
+
collection(name) {
|
|
250
|
+
const m = () => of(name);
|
|
251
|
+
return {
|
|
252
|
+
async insert(doc) {
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
const id = typeof doc._id === 'string' ? doc._id : newDocId();
|
|
255
|
+
const saved = withMeta(doc, id, now, now);
|
|
256
|
+
m().set(id, saved);
|
|
257
|
+
return saved;
|
|
258
|
+
},
|
|
259
|
+
async insertMany(docs) {
|
|
260
|
+
const ids = [];
|
|
261
|
+
for (const d of docs)
|
|
262
|
+
ids.push((await this.insert(d))._id);
|
|
263
|
+
return ids;
|
|
264
|
+
},
|
|
265
|
+
async get(id) { return m().get(id) ?? null; },
|
|
266
|
+
async find(options) { return queryDocs([...m().values()], options); },
|
|
267
|
+
async findOne(filter, options) { return (await this.find({ ...options, filter, limit: 1 })).docs[0] ?? null; },
|
|
268
|
+
async count(filter) { return [...m().values()].filter(d => matchesFilter(d, filter)).length; },
|
|
269
|
+
async update(id, input) {
|
|
270
|
+
const cur = m().get(id);
|
|
271
|
+
if (!cur) {
|
|
272
|
+
if (!input.upsert)
|
|
273
|
+
return null;
|
|
274
|
+
return this.insert({ ...(input.set ?? {}), _id: id });
|
|
275
|
+
}
|
|
276
|
+
const next = applyUpdate(cur, input);
|
|
277
|
+
m().set(id, next);
|
|
278
|
+
return next;
|
|
279
|
+
},
|
|
280
|
+
async replace(id, doc) {
|
|
281
|
+
const cur = m().get(id);
|
|
282
|
+
const saved = withMeta(doc, id, cur?._createdAt ?? Date.now(), Date.now());
|
|
283
|
+
m().set(id, saved);
|
|
284
|
+
return saved;
|
|
285
|
+
},
|
|
286
|
+
async delete(id) { return m().delete(id); },
|
|
287
|
+
async deleteMany(filter) {
|
|
288
|
+
let n = 0;
|
|
289
|
+
for (const [id, d] of [...m().entries()])
|
|
290
|
+
if (matchesFilter(d, filter)) {
|
|
291
|
+
m().delete(id);
|
|
292
|
+
n++;
|
|
293
|
+
}
|
|
294
|
+
return n;
|
|
295
|
+
},
|
|
296
|
+
async drop() { store.delete(name); },
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
let cached = null;
|
|
302
|
+
export function getDb() {
|
|
303
|
+
const cfg = resolveConfig();
|
|
304
|
+
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}`
|
|
305
|
+
: cfg.kind === 'edgeone' ? `edgeone|${cfg.kvStore}|${cfg.projectId ?? ''}`
|
|
306
|
+
: 'memory';
|
|
307
|
+
if (!cached || cached.key !== key) {
|
|
308
|
+
cached = {
|
|
309
|
+
key,
|
|
310
|
+
client: cfg.kind === 'platform' ? platformDb(cfg)
|
|
311
|
+
: cfg.kind === 'edgeone' ? edgeoneDb(cfg)
|
|
312
|
+
: memoryDb(),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return cached.client;
|
|
316
|
+
}
|
|
317
|
+
/** 便捷单例:`import { db } from '@chatu-ai/app-sdk'` */
|
|
318
|
+
export const db = {
|
|
319
|
+
collection: (name) => getDb().collection(name),
|
|
320
|
+
collections: () => getDb().collections(),
|
|
321
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/db.test.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { beforeEach, describe as d, expect, it } from 'vitest';
|
|
2
|
+
import { configure, db, describe, matchesFilter, registerOptionalModule } from './index';
|
|
3
|
+
async function seed() {
|
|
4
|
+
const c = db.collection('todos');
|
|
5
|
+
await c.insert({ title: '买牛奶', done: false, priority: 2, tags: ['家务'], owner: { name: 'a' } });
|
|
6
|
+
await new Promise(r => setTimeout(r, 2));
|
|
7
|
+
await c.insert({ title: '写周报', done: true, priority: 1, tags: ['工作'], owner: { name: 'b' } });
|
|
8
|
+
await new Promise(r => setTimeout(r, 2));
|
|
9
|
+
await c.insert({ title: '订机票', done: false, priority: 3, tags: ['工作', '出差'] });
|
|
10
|
+
return c;
|
|
11
|
+
}
|
|
12
|
+
d('memory driver: db', () => {
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
configure({ driver: 'memory' });
|
|
15
|
+
await db.collection('todos').drop(); // 内存驱动跨用例保留数据,每个用例从干净集合开始
|
|
16
|
+
});
|
|
17
|
+
it('insert 补 _id/_createdAt/_updatedAt,get/find 可读回', async () => {
|
|
18
|
+
expect(describe().driver).toBe('memory');
|
|
19
|
+
const c = await seed();
|
|
20
|
+
const { docs, total, nextSkip } = await c.find({ sort: { _createdAt: 1 } });
|
|
21
|
+
expect(total).toBe(3);
|
|
22
|
+
expect(nextSkip).toBeNull();
|
|
23
|
+
expect(docs.map(t => t.title)).toEqual(['买牛奶', '写周报', '订机票']);
|
|
24
|
+
expect(docs[0]._id).toMatch(/^[0-9a-z]+$/);
|
|
25
|
+
expect(docs[0]._createdAt).toBeLessThanOrEqual(docs[1]._createdAt);
|
|
26
|
+
expect(await c.get(docs[0]._id)).toMatchObject({ title: '买牛奶' });
|
|
27
|
+
expect(await c.get('nope')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
it('filter:等值 / 操作符 / 嵌套路径 / $or', async () => {
|
|
30
|
+
const c = await seed();
|
|
31
|
+
expect((await c.find({ filter: { done: false } })).total).toBe(2);
|
|
32
|
+
expect((await c.find({ filter: { priority: { $gte: 2 } } })).total).toBe(2);
|
|
33
|
+
expect((await c.find({ filter: { title: { $contains: '牛奶' } } })).total).toBe(1);
|
|
34
|
+
expect((await c.find({ filter: { tags: { $contains: '工作' } } })).total).toBe(2);
|
|
35
|
+
expect((await c.find({ filter: { 'owner.name': 'b' } })).total).toBe(1);
|
|
36
|
+
expect((await c.find({ filter: { owner: { $exists: false } } })).total).toBe(1);
|
|
37
|
+
expect((await c.find({ filter: { priority: { $in: [1, 3] } } })).total).toBe(2);
|
|
38
|
+
expect((await c.find({ filter: { $or: [{ done: true }, { priority: 3 }] } })).total).toBe(2);
|
|
39
|
+
expect(await c.count({ done: true })).toBe(1);
|
|
40
|
+
expect((await c.findOne({ done: true }))?.title).toBe('写周报');
|
|
41
|
+
});
|
|
42
|
+
it('sort + 分页:nextSkip 串起下一页', async () => {
|
|
43
|
+
const c = await seed();
|
|
44
|
+
const p1 = await c.find({ sort: { priority: -1 }, limit: 2 });
|
|
45
|
+
expect(p1.docs.map(t => t.priority)).toEqual([3, 2]);
|
|
46
|
+
expect(p1.nextSkip).toBe(2);
|
|
47
|
+
const p2 = await c.find({ sort: { priority: -1 }, skip: p1.nextSkip, limit: 2 });
|
|
48
|
+
expect(p2.docs.map(t => t.priority)).toEqual([1]);
|
|
49
|
+
expect(p2.nextSkip).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
it('update:set / unset / inc / upsert,replace 保留 _createdAt', async () => {
|
|
52
|
+
const c = await seed();
|
|
53
|
+
const first = (await c.find({ sort: { _createdAt: 1 }, limit: 1 })).docs[0];
|
|
54
|
+
const updated = await c.update(first._id, { set: { done: true }, inc: { priority: 10 }, unset: ['tags'] });
|
|
55
|
+
expect(updated).toMatchObject({ done: true, priority: 12 });
|
|
56
|
+
expect(updated.tags).toBeUndefined();
|
|
57
|
+
expect(updated._createdAt).toBe(first._createdAt);
|
|
58
|
+
expect(updated._updatedAt).toBeGreaterThanOrEqual(first._updatedAt);
|
|
59
|
+
expect(await c.update('missing', { set: { done: true } })).toBeNull();
|
|
60
|
+
const upserted = await c.update('fixed-id', { set: { title: '新建', done: false }, upsert: true });
|
|
61
|
+
expect(upserted).toMatchObject({ _id: 'fixed-id', title: '新建' });
|
|
62
|
+
const replaced = await c.replace(first._id, { title: '换掉了', done: false });
|
|
63
|
+
expect(replaced.title).toBe('换掉了');
|
|
64
|
+
expect(replaced._createdAt).toBe(first._createdAt);
|
|
65
|
+
});
|
|
66
|
+
it('delete / deleteMany / drop / collections', async () => {
|
|
67
|
+
const c = await seed();
|
|
68
|
+
const one = (await c.find({ limit: 1 })).docs[0];
|
|
69
|
+
expect(await c.delete(one._id)).toBe(true);
|
|
70
|
+
expect(await c.delete(one._id)).toBe(false);
|
|
71
|
+
expect(await c.deleteMany({ done: true })).toBe(1);
|
|
72
|
+
expect((await db.collections()).find(x => x.name === 'todos')?.count).toBe(1);
|
|
73
|
+
await c.drop();
|
|
74
|
+
expect((await c.find()).total).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
d('platform driver: db', () => {
|
|
78
|
+
it('调用 Data API 并透传 filter/sort/分页', async () => {
|
|
79
|
+
const calls = [];
|
|
80
|
+
const fetchImpl = (async (url, init) => {
|
|
81
|
+
calls.push({ url, init });
|
|
82
|
+
if (url.endsWith('/db/todos/query')) {
|
|
83
|
+
return new Response(JSON.stringify({ ok: true, docs: [{ _id: 'a', _createdAt: 1, _updatedAt: 1, title: 'x' }], total: 3, nextSkip: 1 }), { status: 200 });
|
|
84
|
+
}
|
|
85
|
+
if (url.endsWith('/db/todos/count?filter=%7B%22done%22%3Afalse%7D'))
|
|
86
|
+
return new Response(JSON.stringify({ ok: true, count: 2 }), { status: 200 });
|
|
87
|
+
if (url.endsWith('/db/todos/a'))
|
|
88
|
+
return new Response(JSON.stringify({ ok: true, exists: true, doc: { _id: 'a', _createdAt: 1, _updatedAt: 1, title: 'x' } }), { status: 200 });
|
|
89
|
+
if (url.endsWith('/db/todos') && init.method === 'POST')
|
|
90
|
+
return new Response(JSON.stringify({ ok: true, ids: ['a'] }), { status: 200 });
|
|
91
|
+
return new Response(JSON.stringify({ ok: false, error: 'UNEXPECTED', url }), { status: 400 });
|
|
92
|
+
});
|
|
93
|
+
configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', env: 'prod', fetchImpl });
|
|
94
|
+
const c = db.collection('todos');
|
|
95
|
+
const saved = await c.insert({ title: 'x', done: false });
|
|
96
|
+
expect(saved._id).toBe('a');
|
|
97
|
+
const r = await c.find({ filter: { done: false }, sort: { _createdAt: -1 }, limit: 1 });
|
|
98
|
+
expect(r.total).toBe(3);
|
|
99
|
+
expect(r.nextSkip).toBe(1);
|
|
100
|
+
expect(await c.count({ done: false })).toBe(2);
|
|
101
|
+
const query = calls.find(x => x.url.endsWith('/query'));
|
|
102
|
+
expect(JSON.parse(String(query.init.body))).toEqual({ filter: { done: false }, sort: { _createdAt: -1 }, limit: 1 });
|
|
103
|
+
expect(query.init.headers['x-api-key']).toBe('sk-conv-abc');
|
|
104
|
+
expect(query.init.headers['x-chatu-env']).toBe('prod');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
d('edgeone driver: db', () => {
|
|
108
|
+
it('文档落在 Blob 的 db/{coll}/{id},查询在内存过滤', async () => {
|
|
109
|
+
const stores = new Map();
|
|
110
|
+
const getStore = (arg) => {
|
|
111
|
+
const name = typeof arg === 'string' ? arg : arg.name;
|
|
112
|
+
let m = stores.get(name);
|
|
113
|
+
if (!m) {
|
|
114
|
+
m = new Map();
|
|
115
|
+
stores.set(name, m);
|
|
116
|
+
}
|
|
117
|
+
const map = m;
|
|
118
|
+
return {
|
|
119
|
+
async setJSON(key, value) { map.set(key, value); },
|
|
120
|
+
async set(key, value) { map.set(key, value); },
|
|
121
|
+
async get(key) { return map.get(key) ?? null; },
|
|
122
|
+
async delete(key) { map.delete(key); },
|
|
123
|
+
async list(opts) {
|
|
124
|
+
return { blobs: [...map.keys()].filter(k => k.startsWith(opts?.prefix ?? '')).sort().map(key => ({ key, etag: 'x' })) };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
registerOptionalModule('@edgeone/pages-blob', { getStore });
|
|
129
|
+
configure({ driver: 'edgeone' });
|
|
130
|
+
const c = db.collection('todos');
|
|
131
|
+
await c.insert({ title: '买牛奶', done: false, priority: 2 });
|
|
132
|
+
await c.insert({ title: '写周报', done: true, priority: 1 });
|
|
133
|
+
expect([...stores.get('chatu-kv').keys()].every(k => k.startsWith('db/todos/'))).toBe(true);
|
|
134
|
+
expect((await c.find({ filter: { done: false } })).total).toBe(1);
|
|
135
|
+
expect((await c.find({ sort: { priority: -1 } })).docs[0].title).toBe('买牛奶');
|
|
136
|
+
expect((await db.collections()).find(x => x.name === 'todos')?.count).toBe(2);
|
|
137
|
+
const target = (await c.find({ filter: { done: true } })).docs[0];
|
|
138
|
+
expect((await c.update(target._id, { inc: { priority: 5 } })).priority).toBe(6);
|
|
139
|
+
expect(await c.delete(target._id)).toBe(true);
|
|
140
|
+
expect((await c.find()).total).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
d('matchesFilter 边界', () => {
|
|
144
|
+
it('未知操作符不匹配;$ne 对缺失字段成立;$nin 排除', () => {
|
|
145
|
+
expect(matchesFilter({ a: 1 }, { a: { $unknown: 1 } })).toBe(false);
|
|
146
|
+
expect(matchesFilter({ a: 1 }, { b: { $ne: 2 } })).toBe(true);
|
|
147
|
+
expect(matchesFilter({ a: 1 }, { a: { $nin: [1, 2] } })).toBe(false);
|
|
148
|
+
expect(matchesFilter({ a: 1 }, {})).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/dist/edgeone.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { EdgeoneConfig } from './config.js';
|
|
2
2
|
import type { KvClient } from './kv.js';
|
|
3
3
|
import type { StorageClient } from './storage.js';
|
|
4
|
+
import { type DbClient } from './db.js';
|
|
4
5
|
/** kv 键 → Blob key:逐字符编码保持前缀关系(list(prefix) 可用),'/' 保留为目录分隔 */
|
|
5
6
|
export declare function encodeKvKey(key: string): string;
|
|
6
7
|
export declare function decodeKvKey(key: string): string;
|
|
7
8
|
export declare function edgeoneKv(cfg: EdgeoneConfig): KvClient;
|
|
8
9
|
export declare function edgeoneStorage(cfg: EdgeoneConfig): StorageClient;
|
|
10
|
+
/**
|
|
11
|
+
* EdgeOne Pages Blob 上的文档集合:`db/{coll}/{id}` 一个对象一个文档,
|
|
12
|
+
* 查询用 list(prefix) 拉全量后在内存过滤/排序/分页(与平台驱动同语义,适合万级以内)。
|
|
13
|
+
*/
|
|
14
|
+
export declare function edgeoneDb(cfg: EdgeoneConfig): DbClient;
|
package/dist/edgeone.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { optionalImport } from './config.js';
|
|
2
2
|
import { AppSdkError } from './errors.js';
|
|
3
|
+
import { applyUpdate, newDocId, queryDocs, withMeta } from './db.js';
|
|
3
4
|
const HINT = 'run `npm i @edgeone/pages-blob` (preinstalled in the ChatU Builder template)';
|
|
4
5
|
function storeFactory(cfg) {
|
|
5
6
|
let modPromise = null;
|
|
@@ -203,3 +204,131 @@ export function edgeoneStorage(cfg) {
|
|
|
203
204
|
},
|
|
204
205
|
};
|
|
205
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* EdgeOne Pages Blob 上的文档集合:`db/{coll}/{id}` 一个对象一个文档,
|
|
209
|
+
* 查询用 list(prefix) 拉全量后在内存过滤/排序/分页(与平台驱动同语义,适合万级以内)。
|
|
210
|
+
*/
|
|
211
|
+
export function edgeoneDb(cfg) {
|
|
212
|
+
const getStore = storeFactory(cfg);
|
|
213
|
+
const store = () => getStore(cfg.kvStore);
|
|
214
|
+
const docKey = (coll, id) => `db/${coll}/${id}`;
|
|
215
|
+
async function all(coll) {
|
|
216
|
+
const s = await store();
|
|
217
|
+
let listed;
|
|
218
|
+
try {
|
|
219
|
+
listed = await s.list({ prefix: `db/${coll}/`, paginate: true, consistency: 'strong' });
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
throw wrapErr(e, 'db list');
|
|
223
|
+
}
|
|
224
|
+
const docs = await Promise.all(listed.blobs.map(async (b) => {
|
|
225
|
+
try {
|
|
226
|
+
return (await s.get(b.key, { type: 'json', consistency: 'strong' })) ?? null;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}));
|
|
232
|
+
return docs.filter((d) => d !== null && typeof d === 'object');
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
async collections() {
|
|
236
|
+
const s = await store();
|
|
237
|
+
const listed = await s.list({ prefix: 'db/', directories: true, paginate: true, consistency: 'strong' });
|
|
238
|
+
const counts = new Map();
|
|
239
|
+
for (const b of listed.blobs) {
|
|
240
|
+
const name = b.key.slice(3, b.key.indexOf('/', 3));
|
|
241
|
+
if (name)
|
|
242
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
243
|
+
}
|
|
244
|
+
return [...counts.entries()].map(([name, count]) => ({ name, count }));
|
|
245
|
+
},
|
|
246
|
+
collection(coll) {
|
|
247
|
+
return {
|
|
248
|
+
async insert(doc) {
|
|
249
|
+
const s = await store();
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const id = typeof doc._id === 'string' ? doc._id : newDocId();
|
|
252
|
+
const saved = withMeta(doc, id, now, now);
|
|
253
|
+
try {
|
|
254
|
+
await s.setJSON(docKey(coll, id), saved);
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
throw wrapErr(e, 'db insert');
|
|
258
|
+
}
|
|
259
|
+
return saved;
|
|
260
|
+
},
|
|
261
|
+
async insertMany(docs) {
|
|
262
|
+
const ids = [];
|
|
263
|
+
for (const d of docs)
|
|
264
|
+
ids.push((await this.insert(d))._id);
|
|
265
|
+
return ids;
|
|
266
|
+
},
|
|
267
|
+
async get(id) {
|
|
268
|
+
const s = await store();
|
|
269
|
+
try {
|
|
270
|
+
return (await s.get(docKey(coll, id), { type: 'json', consistency: 'strong' })) ?? null;
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
throw wrapErr(e, 'db get');
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async find(options) { return queryDocs(await all(coll), options); },
|
|
277
|
+
async findOne(filter, options) { return (await this.find({ ...options, filter, limit: 1 })).docs[0] ?? null; },
|
|
278
|
+
async count(filter) { return (await this.find({ filter, limit: 200 })).total; },
|
|
279
|
+
async update(id, input) {
|
|
280
|
+
const cur = await this.get(id);
|
|
281
|
+
if (!cur) {
|
|
282
|
+
if (!input.upsert)
|
|
283
|
+
return null;
|
|
284
|
+
return this.insert({ ...(input.set ?? {}), _id: id });
|
|
285
|
+
}
|
|
286
|
+
const next = applyUpdate(cur, input);
|
|
287
|
+
const s = await store();
|
|
288
|
+
try {
|
|
289
|
+
await s.setJSON(docKey(coll, id), next);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
throw wrapErr(e, 'db update');
|
|
293
|
+
}
|
|
294
|
+
return next;
|
|
295
|
+
},
|
|
296
|
+
async replace(id, doc) {
|
|
297
|
+
const cur = await this.get(id);
|
|
298
|
+
const saved = withMeta(doc, id, cur?._createdAt ?? Date.now(), Date.now());
|
|
299
|
+
const s = await store();
|
|
300
|
+
try {
|
|
301
|
+
await s.setJSON(docKey(coll, id), saved);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
throw wrapErr(e, 'db replace');
|
|
305
|
+
}
|
|
306
|
+
return saved;
|
|
307
|
+
},
|
|
308
|
+
async delete(id) {
|
|
309
|
+
const existed = (await this.get(id)) !== null;
|
|
310
|
+
const s = await store();
|
|
311
|
+
try {
|
|
312
|
+
await s.delete(docKey(coll, id));
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
throw wrapErr(e, 'db delete');
|
|
316
|
+
}
|
|
317
|
+
return existed;
|
|
318
|
+
},
|
|
319
|
+
async deleteMany(filter) {
|
|
320
|
+
const s = await store();
|
|
321
|
+
const docs = (await this.find({ filter, limit: 200 })).docs;
|
|
322
|
+
for (const d of docs)
|
|
323
|
+
await s.delete(docKey(coll, d._id)).catch(() => undefined);
|
|
324
|
+
return docs.length;
|
|
325
|
+
},
|
|
326
|
+
async drop() {
|
|
327
|
+
const s = await store();
|
|
328
|
+
for (const d of await all(coll))
|
|
329
|
+
await s.delete(docKey(coll, d._id)).catch(() => undefined);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type { KvClient, KvSetOptions, KvListResult } from './kv.js';
|
|
|
3
3
|
export { configure, describe, registerOptionalModule } from './config.js';
|
|
4
4
|
export type { ConfigureOptions, DriverKind } from './config.js';
|
|
5
5
|
export { AppSdkError } from './errors.js';
|
|
6
|
+
export { db, getDb, matchesFilter, applySort, queryDocs, newDocId } from './db.js';
|
|
7
|
+
export type { DbClient, Collection, Doc, Filter, FilterOp, Sort, FindOptions, FindResult, UpdateInput } from './db.js';
|
|
6
8
|
export { storage, getStorage } from './storage.js';
|
|
7
9
|
export type { StorageClient, StorageObject, StorageListResult, UploadUrlResult } from './storage.js';
|
|
8
10
|
export { ai, getAi } from './ai.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { kv, getKv } from './kv.js';
|
|
2
2
|
export { configure, describe, registerOptionalModule } from './config.js';
|
|
3
3
|
export { AppSdkError } from './errors.js';
|
|
4
|
+
export { db, getDb, matchesFilter, applySort, queryDocs, newDocId } from './db.js';
|
|
4
5
|
export { storage, getStorage } from './storage.js';
|
|
5
6
|
export { ai, getAi } from './ai.js';
|
|
6
7
|
export { encodeKvKey, decodeKvKey } from './edgeone.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatu-ai/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Runtime SDK for apps generated by ChatU Builder: kv, storage and ai (OpenAI-compatible LLM relay) with platform / byo / memory drivers selected by environment variables",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chatu-db
|
|
3
|
+
description: 平台托管文档集合(@chatu-ai/app-sdk 的 db)。当应用的数据是"一类记录的集合"——待办、文章、订单、评论、报名、库存、客户——需要按条件筛选、排序、分页、统计时使用。比 kv 更合适;仍禁止引入 supabase/prisma/mongoose/mysql 等外部数据库。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 文档集合(db)
|
|
7
|
+
|
|
8
|
+
平台托管的文档数据库:一个集合 = 一类记录,每条记录是一个 JSON 文档。**预览(dev)与线上(prod)同一套 API**,数据都持久保存。
|
|
9
|
+
|
|
10
|
+
## 什么时候用 db,什么时候用 kv
|
|
11
|
+
|
|
12
|
+
| 场景 | 用 | 理由 |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| 待办 / 文章 / 订单 / 评论 / 报名 …(一类记录,会列表展示) | **db** | 天生支持筛选、排序、分页、计数 |
|
|
15
|
+
| 单个配置、开关、计数器、验证码、临时缓存 | **kv**(见 `chatu-kv`) | 一个键一个值,最简单 |
|
|
16
|
+
| 文件本身(图片/附件) | **storage**(见 `chatu-storage`) | db 只存文件的 key 与元数据 |
|
|
17
|
+
|
|
18
|
+
## API(只能在服务端调用)
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { db } from '@/lib/platform';
|
|
22
|
+
|
|
23
|
+
interface Todo { title: string; done: boolean; priority?: number; tags?: string[] }
|
|
24
|
+
const todos = db.collection<Todo>('todos'); // 集合不存在会自动创建
|
|
25
|
+
|
|
26
|
+
const doc = await todos.insert({ title: '买牛奶', done: false }); // 返回补好 _id/_createdAt/_updatedAt 的文档
|
|
27
|
+
const ids = await todos.insertMany([{ title: 'a', done: false }, { title: 'b', done: false }]);
|
|
28
|
+
const one = await todos.get(id); // 不存在 → null
|
|
29
|
+
const first= await todos.findOne({ done: false }); // 第一条匹配 → null
|
|
30
|
+
const { docs, total, nextSkip } = await todos.find({
|
|
31
|
+
filter: { done: false, priority: { $gte: 2 } },
|
|
32
|
+
sort: { _createdAt: -1 }, // 1 升序 / -1 降序,可多字段
|
|
33
|
+
skip: 0, limit: 20, // limit ≤ 200,默认 50
|
|
34
|
+
});
|
|
35
|
+
const n = await todos.count({ done: true });
|
|
36
|
+
await todos.update(id, { set: { done: true }, inc: { views: 1 }, unset: ['draft'] }); // 局部更新
|
|
37
|
+
await todos.update(id, { set: { title: 'x' }, upsert: true }); // 不存在则创建
|
|
38
|
+
await todos.replace(id, { title: '整体替换', done: false });
|
|
39
|
+
await todos.delete(id);
|
|
40
|
+
await todos.deleteMany({ done: true });
|
|
41
|
+
const list = await db.collections(); // [{ name, count }]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
每个文档自带 `_id`(字符串,时间有序)、`_createdAt`、`_updatedAt`(毫秒时间戳)。
|
|
45
|
+
|
|
46
|
+
## 过滤语法
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
{ done: false } // 等值
|
|
50
|
+
{ 'owner.name': '张三' } // 嵌套字段用点路径
|
|
51
|
+
{ priority: { $gt: 1, $lte: 5 } } // $gt $gte $lt $lte
|
|
52
|
+
{ status: { $ne: 'archived' } } // 不等
|
|
53
|
+
{ status: { $in: ['todo', 'doing'] } } // 在集合内 / $nin 不在
|
|
54
|
+
{ title: { $contains: '牛奶' } } // 字符串包含(忽略大小写)
|
|
55
|
+
{ tags: { $contains: '工作' } } // 数组包含某元素
|
|
56
|
+
{ dueAt: { $exists: true } } // 字段存在与否
|
|
57
|
+
{ $or: [{ done: true }, { priority: 3 }] } // $and / $or / $not 组合
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 标准写法:数据访问层 + Server Action
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/lib/todos.ts
|
|
64
|
+
import { db } from '@/lib/platform';
|
|
65
|
+
|
|
66
|
+
export interface Todo { title: string; done: boolean; priority: number }
|
|
67
|
+
const todos = db.collection<Todo>('todos');
|
|
68
|
+
|
|
69
|
+
export async function listTodos(page = 0) {
|
|
70
|
+
return todos.find({ sort: { _createdAt: -1 }, skip: page * 20, limit: 20 });
|
|
71
|
+
}
|
|
72
|
+
export async function addTodo(title: string) {
|
|
73
|
+
return todos.insert({ title, done: false, priority: 2 });
|
|
74
|
+
}
|
|
75
|
+
export async function toggleTodo(id: string, done: boolean) {
|
|
76
|
+
return todos.update(id, { set: { done } });
|
|
77
|
+
}
|
|
78
|
+
export async function removeTodo(id: string) {
|
|
79
|
+
return todos.delete(id);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// src/app/page.tsx
|
|
85
|
+
import { listTodos, addTodo, toggleTodo } from '@/lib/todos';
|
|
86
|
+
import { revalidatePath } from 'next/cache';
|
|
87
|
+
|
|
88
|
+
export default async function Home() {
|
|
89
|
+
const { docs, total } = await listTodos();
|
|
90
|
+
|
|
91
|
+
async function create(formData: FormData) {
|
|
92
|
+
'use server';
|
|
93
|
+
const title = String(formData.get('title') ?? '').trim();
|
|
94
|
+
if (!title) return;
|
|
95
|
+
await addTodo(title);
|
|
96
|
+
revalidatePath('/');
|
|
97
|
+
}
|
|
98
|
+
async function toggle(formData: FormData) {
|
|
99
|
+
'use server';
|
|
100
|
+
await toggleTodo(String(formData.get('id')), formData.get('done') === '1');
|
|
101
|
+
revalidatePath('/');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<main>
|
|
106
|
+
<form action={create}>{/* input name="title" */}</form>
|
|
107
|
+
<p>共 {total} 条</p>
|
|
108
|
+
{docs.map((t) => (
|
|
109
|
+
<form key={t._id} action={toggle}>
|
|
110
|
+
<input type="hidden" name="id" value={t._id} />
|
|
111
|
+
<input type="hidden" name="done" value={t.done ? '0' : '1'} />
|
|
112
|
+
<button type="submit">{t.done ? '已完成' : '待办'} {t.title}</button>
|
|
113
|
+
</form>
|
|
114
|
+
))}
|
|
115
|
+
</main>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
浏览器组件(`'use client'`)不能 import `db`——通过 Server Action 或自己的 `src/app/api/*/route.ts` 间接调用。
|
|
121
|
+
|
|
122
|
+
## 分页
|
|
123
|
+
|
|
124
|
+
`find()` 返回 `total` 与 `nextSkip`(没有下一页时为 `null`):
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
let skip = 0;
|
|
128
|
+
for (;;) {
|
|
129
|
+
const page = await todos.find({ skip, limit: 100 });
|
|
130
|
+
process(page.docs);
|
|
131
|
+
if (page.nextSkip === null) break;
|
|
132
|
+
skip = page.nextSkip;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 边界与禁忌
|
|
137
|
+
|
|
138
|
+
- **只在服务端**调用;前端 import 会报错。
|
|
139
|
+
- 单文档 ≤ 256KB;单集合 ≤ 10,000 文档;单应用 ≤ 50 个集合;`limit` ≤ 200。数据量更大时按时间/用户拆分集合。
|
|
140
|
+
- 没有 join / 事务:关联数据存 id,分两次查;计数用 `update(id, { inc: { views: 1 } })`。
|
|
141
|
+
- 查询在服务端全量扫描后过滤,适合万级以内;别在渲染循环里对每条记录再查一次(N+1),先 `find` 一次再在内存里组装。
|
|
142
|
+
- 不要引入外部数据库/ORM,也不要用 `fs` 存 JSON 文件。
|
|
143
|
+
- 字段名不要以 `_` 开头(`_id`/`_createdAt`/`_updatedAt` 是平台保留字段,写入会被忽略/覆盖)。
|
|
144
|
+
|
|
145
|
+
## 常见错误
|
|
146
|
+
|
|
147
|
+
| 现象 | 原因 | 修法 |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| 新增后页面没变 | Server Component 缓存 | Server Action 里 `revalidatePath()`;或页面 `export const dynamic = "force-dynamic"` |
|
|
150
|
+
| `update` 返回 null | 文档不存在 | 确认 id;需要"没有就创建"时传 `upsert: true` |
|
|
151
|
+
| 列表只有 50 条 | `limit` 默认 50 | 传 `limit`(≤200)并用 `nextSkip` 翻页 |
|
|
152
|
+
| 排序结果不对 | 字段类型混用(字符串与数字混存) | 统一字段类型;时间用毫秒时间戳数字 |
|
|
153
|
+
| `DOC_QUOTA_EXCEEDED` | 单集合超过 1 万条 | 归档旧数据(`deleteMany`)或按月/按用户拆集合 |
|