tina4-nodejs 3.13.38 → 3.13.40
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/CLAUDE.md +54 -5
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +91 -21
- package/packages/core/src/index.ts +9 -4
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +105 -12
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +120 -22
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +21 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +14 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 DocStore - pymongo-style document storage with a zero-config SQLite (JSON1) fallback.
|
|
3
|
+
*
|
|
4
|
+
* A document store with the everyday MongoDB driver collection API, backed by
|
|
5
|
+
* SQLite's JSON1 extension when no MongoDB server is configured.
|
|
6
|
+
*
|
|
7
|
+
* import { getCollection, ObjectId } from "@tina4/orm";
|
|
8
|
+
*
|
|
9
|
+
* const orders = getCollection("orders"); // SqliteCollection when no Mongo configured
|
|
10
|
+
* const { insertedId } = await orders.insertOne({ customer_id: 1, total: 9.99 });
|
|
11
|
+
* for (const o of await orders.find({ customer_id: { $in: [1, 2] } }).sort("created_at", -1).limit(10).toArray()) {
|
|
12
|
+
* // ...
|
|
13
|
+
* }
|
|
14
|
+
* await orders.updateOne({ _id: insertedId }, { $set: { status: "shipped" } });
|
|
15
|
+
*
|
|
16
|
+
* `getCollection(name)` returns a real MongoDB driver `Collection` when a Mongo
|
|
17
|
+
* URI is configured (TINA4_MONGO_URI, else TINA4_SESSION_MONGO_URI - the same
|
|
18
|
+
* names the queue/session Mongo backends read), and otherwise a SqliteCollection
|
|
19
|
+
* backed by a local SQLite file. This mirrors the file-based fallbacks the queue,
|
|
20
|
+
* cache, and session subsystems already provide: an app that talks to Mongo in
|
|
21
|
+
* production runs serverless in local dev with no code change - only the backend
|
|
22
|
+
* differs.
|
|
23
|
+
*
|
|
24
|
+
* Design (the SQLite backend):
|
|
25
|
+
* - Each collection is a table `(_id TEXT PRIMARY KEY, doc TEXT)`; `doc` is JSON.
|
|
26
|
+
* - Query filters are pushed down to SQL over `json_extract(doc, '$.field')`
|
|
27
|
+
* (lazy, not a full in-memory scan), supporting equality, $in/$nin,
|
|
28
|
+
* $gt/$gte/$lt/$lte, $ne, $exists, $regex, and implicit-AND / $or / $and.
|
|
29
|
+
* - Updates: $set, $unset, $inc, and full-document replace.
|
|
30
|
+
* - Cursors: sort / limit / skip / projection.
|
|
31
|
+
* - IDs are a built-in 12-byte ObjectId (zero-dependency; interchangeable with
|
|
32
|
+
* the driver's ObjectId as a 24-hex string).
|
|
33
|
+
*
|
|
34
|
+
* Type round-trip is by value, not by wrapper object, so json_extract stays
|
|
35
|
+
* queryable and sortable: a Date is stored as an ISO-8601 UTC string and an
|
|
36
|
+
* ObjectId as its 24-hex string, and reads rehydrate a strict-ISO string back to
|
|
37
|
+
* a Date and a 24-hex string back to an ObjectId. That keeps range queries and
|
|
38
|
+
* sorts working on date and id fields - the trade-off (a plain 24-hex / ISO
|
|
39
|
+
* string becomes an ObjectId / Date on read) is acceptable for the local dev store.
|
|
40
|
+
*
|
|
41
|
+
* Deliberate non-goals: aggregation pipeline, $elemMatch, geo. This is the
|
|
42
|
+
* everyday CRUD + filter subset, not full Mongo parity.
|
|
43
|
+
*/
|
|
44
|
+
import { DatabaseSync } from "node:sqlite";
|
|
45
|
+
import { randomBytes } from "node:crypto";
|
|
46
|
+
import { mkdirSync } from "node:fs";
|
|
47
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
48
|
+
|
|
49
|
+
// ── ObjectId: zero-dependency 12-byte / 24-hex id ──────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Raised when a value cannot be parsed as an ObjectId. */
|
|
52
|
+
export class InvalidId extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "InvalidId";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const OID_RE = /^[0-9a-fA-F]{24}$/;
|
|
60
|
+
const ISO_RE =
|
|
61
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A 12-byte MongoDB-style ObjectId, with no external dependency.
|
|
65
|
+
*
|
|
66
|
+
* Layout: 4-byte big-endian seconds since epoch, 5-byte per-process random,
|
|
67
|
+
* 3-byte big-endian counter. Renders as a 24-char hex string, so it is
|
|
68
|
+
* interchangeable with the driver's ObjectId wherever the string form is used.
|
|
69
|
+
*/
|
|
70
|
+
export class ObjectId {
|
|
71
|
+
private static _counter = randomBytes(3).readUIntBE(0, 3);
|
|
72
|
+
private static _process = randomBytes(5);
|
|
73
|
+
|
|
74
|
+
private readonly _bytes: Buffer;
|
|
75
|
+
|
|
76
|
+
constructor(oid?: ObjectId | Buffer | Uint8Array | string | null) {
|
|
77
|
+
if (oid === undefined || oid === null) {
|
|
78
|
+
this._bytes = ObjectId._generate();
|
|
79
|
+
} else if (oid instanceof ObjectId) {
|
|
80
|
+
this._bytes = Buffer.from(oid._bytes);
|
|
81
|
+
} else if (oid instanceof Buffer || oid instanceof Uint8Array) {
|
|
82
|
+
if (oid.length !== 12) {
|
|
83
|
+
throw new InvalidId("ObjectId bytes must be exactly 12 bytes");
|
|
84
|
+
}
|
|
85
|
+
this._bytes = Buffer.from(oid);
|
|
86
|
+
} else if (typeof oid === "string") {
|
|
87
|
+
if (!OID_RE.test(oid)) {
|
|
88
|
+
throw new InvalidId(`'${oid}' is not a valid 24-character hex ObjectId`);
|
|
89
|
+
}
|
|
90
|
+
this._bytes = Buffer.from(oid, "hex");
|
|
91
|
+
} else {
|
|
92
|
+
throw new InvalidId(`cannot make an ObjectId from ${typeof oid}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static _generate(): Buffer {
|
|
97
|
+
const buf = Buffer.alloc(12);
|
|
98
|
+
buf.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
|
99
|
+
ObjectId._process.copy(buf, 4);
|
|
100
|
+
ObjectId._counter = (ObjectId._counter + 1) & 0xffffff;
|
|
101
|
+
buf.writeUIntBE(ObjectId._counter, 9, 3);
|
|
102
|
+
return buf;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static isValid(value: unknown): boolean {
|
|
106
|
+
try {
|
|
107
|
+
// eslint-disable-next-line no-new
|
|
108
|
+
new ObjectId(value as never);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get binary(): Buffer {
|
|
116
|
+
return this._bytes;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The timestamp embedded in the id (the first 4 bytes), as a Date. */
|
|
120
|
+
get generationTime(): Date {
|
|
121
|
+
const ts = this._bytes.readUInt32BE(0);
|
|
122
|
+
return new Date(ts * 1000);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
toString(): string {
|
|
126
|
+
return this._bytes.toString("hex");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
toJSON(): string {
|
|
130
|
+
return this.toString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
equals(other: unknown): boolean {
|
|
134
|
+
return other instanceof ObjectId && other._bytes.equals(this._bytes);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Value encoding: keep scalars queryable, rehydrate types on read ─────────
|
|
139
|
+
|
|
140
|
+
function iso(d: Date): string {
|
|
141
|
+
// Date.toISOString() is always UTC with a Z suffix.
|
|
142
|
+
return d.toISOString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Value -> JSON-serialisable, sortable scalar form (for storage/queries). */
|
|
146
|
+
export function encodeValue(value: unknown): unknown {
|
|
147
|
+
if (value instanceof ObjectId) return value.toString();
|
|
148
|
+
if (value instanceof Date) return iso(value);
|
|
149
|
+
if (Array.isArray(value)) return value.map(encodeValue);
|
|
150
|
+
if (value !== null && typeof value === "object") {
|
|
151
|
+
const out: Record<string, unknown> = {};
|
|
152
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
153
|
+
out[k] = encodeValue(v);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Stored JSON value -> rich value, rehydrating ObjectId (24-hex) and Date (ISO). */
|
|
161
|
+
export function decodeValue(value: unknown): unknown {
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
if (OID_RE.test(value)) return new ObjectId(value);
|
|
164
|
+
if (ISO_RE.test(value)) {
|
|
165
|
+
const d = new Date(value);
|
|
166
|
+
return Number.isNaN(d.getTime()) ? value : d;
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(value)) return value.map(decodeValue);
|
|
171
|
+
if (value !== null && typeof value === "object") {
|
|
172
|
+
const out: Record<string, unknown> = {};
|
|
173
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
174
|
+
out[k] = decodeValue(v);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Canonical string key for the _id column. */
|
|
182
|
+
function idKey(value: unknown): string {
|
|
183
|
+
if (value instanceof ObjectId) return value.toString();
|
|
184
|
+
if (value instanceof Date) return iso(value);
|
|
185
|
+
return String(value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Query translation: Mongo filter -> SQL WHERE over json_extract ──────────
|
|
189
|
+
|
|
190
|
+
const COMPARATORS: Record<string, string> = {
|
|
191
|
+
$gt: ">",
|
|
192
|
+
$gte: ">=",
|
|
193
|
+
$lt: "<",
|
|
194
|
+
$lte: "<=",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Field name -> a JSON path. Dotted names address nested keys. */
|
|
198
|
+
function jsonPath(field: string): string {
|
|
199
|
+
const segments = field.split(".");
|
|
200
|
+
const isIdent = (s: string) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s);
|
|
201
|
+
return "$." + segments.map((s) => (isIdent(s) ? s : `"${s}"`)).join(".");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function extract(field: string): string {
|
|
205
|
+
return `json_extract(doc, '${jsonPath(field)}')`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function typeOf(field: string): string {
|
|
209
|
+
return `json_type(doc, '${jsonPath(field)}')`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Bind a value for comparison against json_extract output. */
|
|
213
|
+
function bind(value: unknown): unknown {
|
|
214
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
215
|
+
if (value instanceof ObjectId || value instanceof Date) return encodeValue(value);
|
|
216
|
+
if (value === null || ["number", "string", "bigint"].includes(typeof value)) {
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
return JSON.stringify(encodeValue(value));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface CompiledFilter {
|
|
223
|
+
where: string;
|
|
224
|
+
params: unknown[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Compile a Mongo-style filter object into { where, params }.
|
|
229
|
+
*
|
|
230
|
+
* Returns { where: "1=1", params: [] } for an empty filter. Supports implicit
|
|
231
|
+
* AND across keys, $or / $and, and the per-field operator set.
|
|
232
|
+
*/
|
|
233
|
+
export function compileFilter(query?: Record<string, unknown> | null): CompiledFilter {
|
|
234
|
+
if (!query || Object.keys(query).length === 0) {
|
|
235
|
+
return { where: "1=1", params: [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const clauses: string[] = [];
|
|
239
|
+
const params: unknown[] = [];
|
|
240
|
+
|
|
241
|
+
for (const [key, value] of Object.entries(query)) {
|
|
242
|
+
if (key === "$or" || key === "$and") {
|
|
243
|
+
const joiner = key === "$or" ? " OR " : " AND ";
|
|
244
|
+
const subs: string[] = [];
|
|
245
|
+
for (const sub of value as Record<string, unknown>[]) {
|
|
246
|
+
const compiled = compileFilter(sub);
|
|
247
|
+
subs.push(`(${compiled.where})`);
|
|
248
|
+
params.push(...compiled.params);
|
|
249
|
+
}
|
|
250
|
+
if (subs.length) clauses.push("(" + subs.join(joiner) + ")");
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
value !== null &&
|
|
256
|
+
typeof value === "object" &&
|
|
257
|
+
!Array.isArray(value) &&
|
|
258
|
+
!(value instanceof ObjectId) &&
|
|
259
|
+
!(value instanceof Date) &&
|
|
260
|
+
Object.keys(value).length > 0 &&
|
|
261
|
+
Object.keys(value).every((k) => k.startsWith("$"))
|
|
262
|
+
) {
|
|
263
|
+
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
|
|
264
|
+
const compiled = compileOp(key, op, operand);
|
|
265
|
+
clauses.push(compiled.where);
|
|
266
|
+
params.push(...compiled.params);
|
|
267
|
+
}
|
|
268
|
+
} else if (value === null) {
|
|
269
|
+
clauses.push(`${extract(key)} IS NULL`);
|
|
270
|
+
} else {
|
|
271
|
+
clauses.push(`${extract(key)} = ?`);
|
|
272
|
+
params.push(bind(value));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { where: clauses.length ? clauses.join(" AND ") : "1=1", params };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function compileOp(field: string, op: string, operand: unknown): CompiledFilter {
|
|
280
|
+
const ex = extract(field);
|
|
281
|
+
if (op in COMPARATORS) {
|
|
282
|
+
return { where: `${ex} ${COMPARATORS[op]} ?`, params: [bind(operand)] };
|
|
283
|
+
}
|
|
284
|
+
if (op === "$eq") {
|
|
285
|
+
if (operand === null) return { where: `${ex} IS NULL`, params: [] };
|
|
286
|
+
return { where: `${ex} = ?`, params: [bind(operand)] };
|
|
287
|
+
}
|
|
288
|
+
if (op === "$ne") {
|
|
289
|
+
if (operand === null) return { where: `${ex} IS NOT NULL`, params: [] };
|
|
290
|
+
return { where: `(${ex} <> ? OR ${ex} IS NULL)`, params: [bind(operand)] };
|
|
291
|
+
}
|
|
292
|
+
if (op === "$in") {
|
|
293
|
+
const items = Array.isArray(operand) ? operand : [];
|
|
294
|
+
if (items.length === 0) return { where: "0", params: [] };
|
|
295
|
+
const placeholders = items.map(() => "?").join(",");
|
|
296
|
+
return { where: `${ex} IN (${placeholders})`, params: items.map(bind) };
|
|
297
|
+
}
|
|
298
|
+
if (op === "$nin") {
|
|
299
|
+
const items = Array.isArray(operand) ? operand : [];
|
|
300
|
+
if (items.length === 0) return { where: "1", params: [] };
|
|
301
|
+
const placeholders = items.map(() => "?").join(",");
|
|
302
|
+
return { where: `(${ex} NOT IN (${placeholders}) OR ${ex} IS NULL)`, params: items.map(bind) };
|
|
303
|
+
}
|
|
304
|
+
if (op === "$exists") {
|
|
305
|
+
// json_type is NULL when the path is absent; present-but-null still has a type.
|
|
306
|
+
return { where: operand ? `${typeOf(field)} IS NOT NULL` : `${typeOf(field)} IS NULL`, params: [] };
|
|
307
|
+
}
|
|
308
|
+
if (op === "$regex") {
|
|
309
|
+
let pattern = operand;
|
|
310
|
+
if (operand !== null && typeof operand === "object") {
|
|
311
|
+
pattern = (operand as Record<string, unknown>).$regex ?? "";
|
|
312
|
+
}
|
|
313
|
+
return { where: `${ex} REGEXP ?`, params: [String(pattern)] };
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`DocStore: unsupported query operator '${op}'`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Update + projection helpers ─────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
function project(doc: Record<string, unknown>, projection?: Record<string, unknown> | null): Record<string, unknown> {
|
|
321
|
+
if (!projection || Object.keys(projection).length === 0) return doc;
|
|
322
|
+
const include = new Set(
|
|
323
|
+
Object.entries(projection).filter(([k, v]) => v && k !== "_id").map(([k]) => k),
|
|
324
|
+
);
|
|
325
|
+
const exclude = new Set(Object.entries(projection).filter(([, v]) => !v).map(([k]) => k));
|
|
326
|
+
if (include.size > 0) {
|
|
327
|
+
const out: Record<string, unknown> = {};
|
|
328
|
+
for (const k of include) if (k in doc) out[k] = doc[k];
|
|
329
|
+
const idVal = projection._id;
|
|
330
|
+
if ((idVal === undefined || idVal) && "_id" in doc) out._id = doc._id;
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
// exclusion projection
|
|
334
|
+
const out: Record<string, unknown> = {};
|
|
335
|
+
for (const [k, v] of Object.entries(doc)) if (!exclude.has(k)) out[k] = v;
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function setPath(doc: Record<string, unknown>, dotted: string, value: unknown): void {
|
|
340
|
+
const parts = dotted.split(".");
|
|
341
|
+
let node = doc;
|
|
342
|
+
for (const p of parts.slice(0, -1)) {
|
|
343
|
+
if (node[p] === undefined || node[p] === null || typeof node[p] !== "object") {
|
|
344
|
+
node[p] = {};
|
|
345
|
+
}
|
|
346
|
+
node = node[p] as Record<string, unknown>;
|
|
347
|
+
}
|
|
348
|
+
node[parts[parts.length - 1]] = value;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function unsetPath(doc: Record<string, unknown>, dotted: string): void {
|
|
352
|
+
const parts = dotted.split(".");
|
|
353
|
+
let node: Record<string, unknown> | undefined = doc;
|
|
354
|
+
for (const p of parts.slice(0, -1)) {
|
|
355
|
+
const next = node[p];
|
|
356
|
+
if (next === null || typeof next !== "object") return;
|
|
357
|
+
node = next as Record<string, unknown>;
|
|
358
|
+
}
|
|
359
|
+
if (node) delete node[parts[parts.length - 1]];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getPath(doc: Record<string, unknown>, dotted: string): unknown {
|
|
363
|
+
const parts = dotted.split(".");
|
|
364
|
+
let node: unknown = doc;
|
|
365
|
+
for (const p of parts) {
|
|
366
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
367
|
+
node = (node as Record<string, unknown>)[p];
|
|
368
|
+
}
|
|
369
|
+
return node;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function applyUpdate(doc: Record<string, unknown>, update: Record<string, unknown>): Record<string, unknown> {
|
|
373
|
+
const hasOps = Object.keys(update).some((k) => k.startsWith("$"));
|
|
374
|
+
if (!hasOps) {
|
|
375
|
+
// full-document replace (keep the existing _id)
|
|
376
|
+
const next: Record<string, unknown> = { ...update };
|
|
377
|
+
if (!("_id" in next)) next._id = doc._id;
|
|
378
|
+
return next;
|
|
379
|
+
}
|
|
380
|
+
const next: Record<string, unknown> = { ...doc };
|
|
381
|
+
for (const [op, fields] of Object.entries(update)) {
|
|
382
|
+
const fieldObj = fields as Record<string, unknown>;
|
|
383
|
+
if (op === "$set") {
|
|
384
|
+
for (const [k, v] of Object.entries(fieldObj)) setPath(next, k, v);
|
|
385
|
+
} else if (op === "$unset") {
|
|
386
|
+
for (const k of Object.keys(fieldObj)) unsetPath(next, k);
|
|
387
|
+
} else if (op === "$inc") {
|
|
388
|
+
for (const [k, v] of Object.entries(fieldObj)) {
|
|
389
|
+
const current = getPath(next, k);
|
|
390
|
+
setPath(next, k, (typeof current === "number" ? current : 0) + (v as number));
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
throw new Error(`DocStore: unsupported update operator '${op}'`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return next;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Result shapes (mirror the Mongo driver) ─────────────────────────────────
|
|
400
|
+
|
|
401
|
+
export interface InsertOneResult {
|
|
402
|
+
acknowledged: boolean;
|
|
403
|
+
insertedId: unknown;
|
|
404
|
+
}
|
|
405
|
+
export interface InsertManyResult {
|
|
406
|
+
acknowledged: boolean;
|
|
407
|
+
insertedIds: unknown[];
|
|
408
|
+
}
|
|
409
|
+
export interface UpdateResult {
|
|
410
|
+
acknowledged: boolean;
|
|
411
|
+
matchedCount: number;
|
|
412
|
+
modifiedCount: number;
|
|
413
|
+
upsertedId: unknown | null;
|
|
414
|
+
}
|
|
415
|
+
export interface DeleteResult {
|
|
416
|
+
acknowledged: boolean;
|
|
417
|
+
deletedCount: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Cursor ───────────────────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
/** Lazy result cursor. Builds and runs SQL only when materialised (toArray). */
|
|
423
|
+
export class Cursor {
|
|
424
|
+
private _sort: [string, number][] = [];
|
|
425
|
+
private _limit: number | null = null;
|
|
426
|
+
private _skip = 0;
|
|
427
|
+
|
|
428
|
+
constructor(
|
|
429
|
+
private readonly collection: SqliteCollection,
|
|
430
|
+
private readonly where: string,
|
|
431
|
+
private readonly params: unknown[],
|
|
432
|
+
private readonly projection?: Record<string, unknown> | null,
|
|
433
|
+
) {}
|
|
434
|
+
|
|
435
|
+
sort(keyOrList: string | [string, number][], direction = 1): this {
|
|
436
|
+
if (typeof keyOrList === "string") {
|
|
437
|
+
this._sort.push([keyOrList, direction]);
|
|
438
|
+
} else {
|
|
439
|
+
for (const [k, d] of keyOrList) this._sort.push([k, d]);
|
|
440
|
+
}
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
limit(n: number): this {
|
|
445
|
+
this._limit = Math.trunc(n);
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
skip(n: number): this {
|
|
450
|
+
this._skip = Math.trunc(n);
|
|
451
|
+
return this;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private buildSql(): string {
|
|
455
|
+
let sql = `SELECT doc FROM ${this.collection.quoted} WHERE ${this.where}`;
|
|
456
|
+
if (this._sort.length) {
|
|
457
|
+
const order = this._sort
|
|
458
|
+
.map(([k, d]) => `${extract(k)} ${d < 0 ? "DESC" : "ASC"}`)
|
|
459
|
+
.join(", ");
|
|
460
|
+
sql += ` ORDER BY ${order}`;
|
|
461
|
+
}
|
|
462
|
+
if (this._limit !== null) {
|
|
463
|
+
sql += ` LIMIT ${Math.trunc(this._limit)}`;
|
|
464
|
+
if (this._skip) sql += ` OFFSET ${Math.trunc(this._skip)}`;
|
|
465
|
+
} else if (this._skip) {
|
|
466
|
+
sql += ` LIMIT -1 OFFSET ${Math.trunc(this._skip)}`;
|
|
467
|
+
}
|
|
468
|
+
return sql;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Materialise the cursor into an array of decoded documents. */
|
|
472
|
+
toArray(): Record<string, unknown>[] {
|
|
473
|
+
const rows = this.collection.connection
|
|
474
|
+
.prepare(this.buildSql())
|
|
475
|
+
.all(...(this.params as never[])) as { doc: string }[];
|
|
476
|
+
return rows.map((r) => this.collection.load(r.doc, this.projection));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Alias for toArray() (pymongo's to_list / driver's toArray). */
|
|
480
|
+
toList(length?: number): Record<string, unknown>[] {
|
|
481
|
+
const out = this.toArray();
|
|
482
|
+
return length === undefined ? out : out.slice(0, length);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
[Symbol.iterator](): Iterator<Record<string, unknown>> {
|
|
486
|
+
return this.toArray()[Symbol.iterator]();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Collection ─────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
/** A SQLite-backed collection exposing the everyday MongoDB driver API. */
|
|
493
|
+
export class SqliteCollection {
|
|
494
|
+
readonly quoted: string;
|
|
495
|
+
|
|
496
|
+
constructor(
|
|
497
|
+
readonly connection: DatabaseSync,
|
|
498
|
+
private readonly name: string,
|
|
499
|
+
) {
|
|
500
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
501
|
+
throw new Error(`DocStore: invalid collection name '${name}'`);
|
|
502
|
+
}
|
|
503
|
+
this.quoted = `"${name}"`;
|
|
504
|
+
this.connection.exec(
|
|
505
|
+
`CREATE TABLE IF NOT EXISTS ${this.quoted} (_id TEXT PRIMARY KEY, doc TEXT NOT NULL)`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private dump(document: Record<string, unknown>): string {
|
|
510
|
+
return JSON.stringify(encodeValue(document));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Decode a stored JSON document (rehydrating ObjectId/Date), with optional projection. */
|
|
514
|
+
load(docText: string, projection?: Record<string, unknown> | null): Record<string, unknown> {
|
|
515
|
+
const doc = decodeValue(JSON.parse(docText)) as Record<string, unknown>;
|
|
516
|
+
return projection ? project(doc, projection) : doc;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// -- writes --
|
|
520
|
+
insertOne(document: Record<string, unknown>): InsertOneResult {
|
|
521
|
+
const doc = { ...document };
|
|
522
|
+
if (!("_id" in doc)) doc._id = new ObjectId();
|
|
523
|
+
this.connection
|
|
524
|
+
.prepare(`INSERT INTO ${this.quoted} (_id, doc) VALUES (?, ?)`)
|
|
525
|
+
.run(idKey(doc._id), this.dump(doc));
|
|
526
|
+
return { acknowledged: true, insertedId: doc._id };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
insertMany(documents: Record<string, unknown>[]): InsertManyResult {
|
|
530
|
+
const ids: unknown[] = [];
|
|
531
|
+
const stmt = this.connection.prepare(`INSERT INTO ${this.quoted} (_id, doc) VALUES (?, ?)`);
|
|
532
|
+
for (const document of documents) {
|
|
533
|
+
const doc = { ...document };
|
|
534
|
+
if (!("_id" in doc)) doc._id = new ObjectId();
|
|
535
|
+
ids.push(doc._id);
|
|
536
|
+
stmt.run(idKey(doc._id), this.dump(doc));
|
|
537
|
+
}
|
|
538
|
+
return { acknowledged: true, insertedIds: ids };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// -- reads --
|
|
542
|
+
find(filter?: Record<string, unknown> | null, projection?: Record<string, unknown> | null): Cursor {
|
|
543
|
+
const { where, params } = compileFilter(filter ?? {});
|
|
544
|
+
return new Cursor(this, where, params, projection);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
findOne(
|
|
548
|
+
filter?: Record<string, unknown> | null,
|
|
549
|
+
projection?: Record<string, unknown> | null,
|
|
550
|
+
): Record<string, unknown> | null {
|
|
551
|
+
const results = this.find(filter, projection).limit(1).toArray();
|
|
552
|
+
return results.length ? results[0] : null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
countDocuments(filter?: Record<string, unknown> | null): number {
|
|
556
|
+
const { where, params } = compileFilter(filter ?? {});
|
|
557
|
+
const row = this.connection
|
|
558
|
+
.prepare(`SELECT count(*) AS c FROM ${this.quoted} WHERE ${where}`)
|
|
559
|
+
.get(...(params as never[])) as { c: number | bigint };
|
|
560
|
+
return Number(row.c);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
estimatedDocumentCount(): number {
|
|
564
|
+
const row = this.connection
|
|
565
|
+
.prepare(`SELECT count(*) AS c FROM ${this.quoted}`)
|
|
566
|
+
.get() as { c: number | bigint };
|
|
567
|
+
return Number(row.c);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
distinct(key: string, filter?: Record<string, unknown> | null): unknown[] {
|
|
571
|
+
const seen: unknown[] = [];
|
|
572
|
+
for (const doc of this.find(filter).toArray()) {
|
|
573
|
+
const v = doc[key];
|
|
574
|
+
if (!seen.some((s) => valuesEqual(s, v))) seen.push(v);
|
|
575
|
+
}
|
|
576
|
+
return seen;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// -- updates (filter pushed to SQL; mutation applied per matched doc) --
|
|
580
|
+
private matchingRows(filter?: Record<string, unknown> | null): { _id: string; doc: string }[] {
|
|
581
|
+
const { where, params } = compileFilter(filter ?? {});
|
|
582
|
+
return this.connection
|
|
583
|
+
.prepare(`SELECT _id, doc FROM ${this.quoted} WHERE ${where}`)
|
|
584
|
+
.all(...(params as never[])) as { _id: string; doc: string }[];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private firstMatch(filter?: Record<string, unknown> | null): { _id: string; doc: string } | null {
|
|
588
|
+
const { where, params } = compileFilter(filter ?? {});
|
|
589
|
+
const row = this.connection
|
|
590
|
+
.prepare(`SELECT _id, doc FROM ${this.quoted} WHERE ${where} LIMIT 1`)
|
|
591
|
+
.get(...(params as never[])) as { _id: string; doc: string } | undefined;
|
|
592
|
+
return row ?? null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private writeBack(oldId: string, newDoc: Record<string, unknown>): void {
|
|
596
|
+
this.connection
|
|
597
|
+
.prepare(`UPDATE ${this.quoted} SET _id = ?, doc = ? WHERE _id = ?`)
|
|
598
|
+
.run(idKey(newDoc._id), this.dump(newDoc), oldId);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private doUpsert(filter: Record<string, unknown> | null | undefined, update: Record<string, unknown>): UpdateResult {
|
|
602
|
+
// Seed a document from the filter's equality terms, then apply the update.
|
|
603
|
+
const seed: Record<string, unknown> = {};
|
|
604
|
+
for (const [k, v] of Object.entries(filter ?? {})) {
|
|
605
|
+
if (!k.startsWith("$") && !(v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof ObjectId) && !(v instanceof Date))) {
|
|
606
|
+
seed[k] = v;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const doc = applyUpdate(seed, update);
|
|
610
|
+
if (!("_id" in doc)) doc._id = new ObjectId();
|
|
611
|
+
this.insertOne(doc);
|
|
612
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: doc._id };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
updateOne(
|
|
616
|
+
filter: Record<string, unknown> | null | undefined,
|
|
617
|
+
update: Record<string, unknown>,
|
|
618
|
+
options?: { upsert?: boolean },
|
|
619
|
+
): UpdateResult {
|
|
620
|
+
const row = this.firstMatch(filter);
|
|
621
|
+
if (!row) {
|
|
622
|
+
if (options?.upsert) return this.doUpsert(filter, update);
|
|
623
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
624
|
+
}
|
|
625
|
+
const doc = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
|
|
626
|
+
const newDoc = applyUpdate(doc, update);
|
|
627
|
+
this.writeBack(row._id, newDoc);
|
|
628
|
+
return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
updateMany(
|
|
632
|
+
filter: Record<string, unknown> | null | undefined,
|
|
633
|
+
update: Record<string, unknown>,
|
|
634
|
+
options?: { upsert?: boolean },
|
|
635
|
+
): UpdateResult {
|
|
636
|
+
const rows = this.matchingRows(filter);
|
|
637
|
+
if (rows.length === 0 && options?.upsert) return this.doUpsert(filter, update);
|
|
638
|
+
let matched = 0;
|
|
639
|
+
let modified = 0;
|
|
640
|
+
for (const row of rows) {
|
|
641
|
+
matched += 1;
|
|
642
|
+
const doc = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
|
|
643
|
+
const newDoc = applyUpdate(doc, update);
|
|
644
|
+
this.writeBack(row._id, newDoc);
|
|
645
|
+
modified += 1;
|
|
646
|
+
}
|
|
647
|
+
return { acknowledged: true, matchedCount: matched, modifiedCount: modified, upsertedId: null };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
replaceOne(
|
|
651
|
+
filter: Record<string, unknown> | null | undefined,
|
|
652
|
+
replacement: Record<string, unknown>,
|
|
653
|
+
options?: { upsert?: boolean },
|
|
654
|
+
): UpdateResult {
|
|
655
|
+
const row = this.firstMatch(filter);
|
|
656
|
+
if (!row) {
|
|
657
|
+
if (options?.upsert) {
|
|
658
|
+
const doc = { ...replacement };
|
|
659
|
+
if (!("_id" in doc)) doc._id = new ObjectId();
|
|
660
|
+
this.insertOne(doc);
|
|
661
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: doc._id };
|
|
662
|
+
}
|
|
663
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
664
|
+
}
|
|
665
|
+
const doc = { ...replacement };
|
|
666
|
+
if (!("_id" in doc)) {
|
|
667
|
+
const existing = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
|
|
668
|
+
doc._id = existing._id;
|
|
669
|
+
}
|
|
670
|
+
this.writeBack(row._id, doc);
|
|
671
|
+
return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// -- deletes --
|
|
675
|
+
deleteOne(filter?: Record<string, unknown> | null): DeleteResult {
|
|
676
|
+
const row = this.firstMatch(filter);
|
|
677
|
+
if (!row) return { acknowledged: true, deletedCount: 0 };
|
|
678
|
+
this.connection.prepare(`DELETE FROM ${this.quoted} WHERE _id = ?`).run(row._id);
|
|
679
|
+
return { acknowledged: true, deletedCount: 1 };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
deleteMany(filter?: Record<string, unknown> | null): DeleteResult {
|
|
683
|
+
const { where, params } = compileFilter(filter ?? {});
|
|
684
|
+
const result = this.connection
|
|
685
|
+
.prepare(`DELETE FROM ${this.quoted} WHERE ${where}`)
|
|
686
|
+
.run(...(params as never[]));
|
|
687
|
+
return { acknowledged: true, deletedCount: Number(result.changes) };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
drop(): void {
|
|
691
|
+
this.connection.exec(`DROP TABLE IF EXISTS ${this.quoted}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Structural equality for distinct() (handles ObjectId / Date by value). */
|
|
696
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
697
|
+
if (a instanceof ObjectId && b instanceof ObjectId) return a.equals(b);
|
|
698
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
699
|
+
return a === b;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── REGEXP user function ─────────────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
function regexpFn(pattern: unknown, value: unknown): number {
|
|
705
|
+
if (value === null || value === undefined) return 0;
|
|
706
|
+
try {
|
|
707
|
+
return new RegExp(String(pattern)).test(String(value)) ? 1 : 0;
|
|
708
|
+
} catch {
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Database + selection ─────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
/** Resolve a SQLite path against cwd, auto-mkdir only under cwd. */
|
|
716
|
+
function resolveStorePath(dbPath: string): string {
|
|
717
|
+
if (dbPath === ":memory:") return dbPath;
|
|
718
|
+
let path = dbPath;
|
|
719
|
+
if (!isAbsolute(path)) {
|
|
720
|
+
path = join(process.cwd(), path);
|
|
721
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
722
|
+
}
|
|
723
|
+
return path;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** A SQLite-backed document database (a file of collection tables). */
|
|
727
|
+
export class SqliteDatabase {
|
|
728
|
+
readonly path: string;
|
|
729
|
+
private readonly conn: DatabaseSync;
|
|
730
|
+
private readonly collections = new Map<string, SqliteCollection>();
|
|
731
|
+
|
|
732
|
+
constructor(path?: string) {
|
|
733
|
+
this.path = path || process.env.TINA4_DOC_STORE_PATH || "data/tina4_docstore.db";
|
|
734
|
+
const resolved = resolveStorePath(this.path);
|
|
735
|
+
this.conn = new DatabaseSync(resolved);
|
|
736
|
+
this.conn.function("regexp", regexpFn);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
getCollection(name: string): SqliteCollection {
|
|
740
|
+
let col = this.collections.get(name);
|
|
741
|
+
if (!col) {
|
|
742
|
+
col = new SqliteCollection(this.conn, name);
|
|
743
|
+
this.collections.set(name, col);
|
|
744
|
+
}
|
|
745
|
+
return col;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
listCollectionNames(): string[] {
|
|
749
|
+
const rows = this.conn
|
|
750
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
751
|
+
.all() as { name: string }[];
|
|
752
|
+
return rows.map((r) => r.name);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
close(): void {
|
|
756
|
+
this.conn.close();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The configured Mongo URI, reusing the app-wide queue/session env vars.
|
|
762
|
+
* Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
|
|
763
|
+
*/
|
|
764
|
+
function mongoUri(): string {
|
|
765
|
+
return (
|
|
766
|
+
process.env.TINA4_MONGO_URI ||
|
|
767
|
+
process.env.TINA4_SESSION_MONGO_URI ||
|
|
768
|
+
process.env.TINA4_SESSION_MONGO_URL ||
|
|
769
|
+
""
|
|
770
|
+
).trim();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** True when no Mongo is configured, so the SQLite fallback is in effect. */
|
|
774
|
+
export function isServerless(): boolean {
|
|
775
|
+
if (!mongoUri()) return true;
|
|
776
|
+
// A URI is set: the real-Mongo path is used (the `mongodb` driver is resolved
|
|
777
|
+
// lazily inside getCollection). isServerless() reflects only the configuration.
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let defaultDb: SqliteDatabase | null = null;
|
|
782
|
+
|
|
783
|
+
function getDb(): SqliteDatabase {
|
|
784
|
+
if (defaultDb === null) defaultDb = new SqliteDatabase();
|
|
785
|
+
return defaultDb;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Return a collection for `name`.
|
|
790
|
+
*
|
|
791
|
+
* A real MongoDB driver `Collection` when a Mongo URI is configured (and the
|
|
792
|
+
* `mongodb` driver is installed); otherwise a `SqliteCollection` backed by the
|
|
793
|
+
* local SQLite file. Same call sites either way - only the backend differs.
|
|
794
|
+
*
|
|
795
|
+
* The real-Mongo path is async (the driver connects lazily), so this returns a
|
|
796
|
+
* Promise when Mongo is configured. In serverless mode it returns a
|
|
797
|
+
* SqliteCollection synchronously (the common local-dev case).
|
|
798
|
+
*/
|
|
799
|
+
export function getCollection(name: string): SqliteCollection | Promise<unknown> {
|
|
800
|
+
if (isServerless()) {
|
|
801
|
+
return getDb().getCollection(name);
|
|
802
|
+
}
|
|
803
|
+
return (async () => {
|
|
804
|
+
const { MongoClient } = await import("mongodb");
|
|
805
|
+
const uri = mongoUri();
|
|
806
|
+
const dbName = process.env.TINA4_MONGO_DB || process.env.TINA4_SESSION_MONGO_DB || "tina4";
|
|
807
|
+
const client = new MongoClient(uri);
|
|
808
|
+
await client.connect();
|
|
809
|
+
return client.db(dbName).collection(name);
|
|
810
|
+
})();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Drop the cached default SQLite store (test helper). */
|
|
814
|
+
export function resetDefaultStore(): void {
|
|
815
|
+
if (defaultDb !== null) {
|
|
816
|
+
defaultDb.close();
|
|
817
|
+
defaultDb = null;
|
|
818
|
+
}
|
|
819
|
+
}
|