@primate/mongodb 0.5.0 → 0.7.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.
@@ -1,6 +1,6 @@
1
- import type { TypedArray } from "@rcompat/type";
2
- import type { Binary, Decimal128, ObjectId } from "mongodb";
3
- type Param = bigint | Binary | boolean | Date | Decimal128 | number | ObjectId | string | TypedArray;
1
+ import type { JSONValue, TypedArray } from "@rcompat/type";
2
+ import type { Binary, Decimal128, UUID } from "mongodb";
3
+ type Param = bigint | Binary | boolean | Date | Decimal128 | number | string | TypedArray | JSONValue;
4
4
  type Validate<T extends {
5
5
  [K in keyof T]: Param;
6
6
  }> = T;
@@ -12,10 +12,10 @@ type ColumnTypes = Validate<{
12
12
  DOUBLE: number;
13
13
  INT: number;
14
14
  LONG: bigint;
15
- PRIMARY: ObjectId;
16
15
  STRING: string;
17
16
  TIME: string;
18
- UUID: string;
17
+ UUID: UUID;
18
+ JSON: JSONValue;
19
19
  }>;
20
20
  export type { ColumnTypes as default };
21
21
  //# sourceMappingURL=ColumnTypes.d.ts.map
@@ -1,22 +1,26 @@
1
- import type { As, DataDict, DB, Sort, With } from "@primate/core/db";
1
+ import type { As, DataDict, DB, Schema, Sort, With } from "@primate/core/db";
2
2
  import type { Dict } from "@rcompat/type";
3
- import type { StoreSchema } from "pema";
4
- declare const schema: import("pema").ObjectType<{
5
- database: import("pema").StringType;
6
- host: import("pema").DefaultType<import("pema").StringType, "localhost">;
7
- password: import("pema").OptionalType<import("pema").StringType>;
8
- port: import("pema").DefaultType<import("pema").UintType<"u32">, 27017>;
9
- username: import("pema").OptionalType<import("pema").StringType>;
3
+ import { MongoClient } from "mongodb";
4
+ declare const schema: import("pema").ObjectType<import("pema").NormalizeSchemaObject<{
5
+ readonly host: import("pema").DefaultType<import("pema").StringType, "localhost">;
6
+ readonly port: import("pema").DefaultType<import("pema").UintType<"u32">, 27017>;
7
+ readonly database: import("pema").StringType;
8
+ readonly username: import("pema").OptionalType<import("pema").StringType>;
9
+ readonly password: import("pema").OptionalType<import("pema").StringType>;
10
+ }>, {
11
+ host: string;
12
+ port: number;
13
+ database: string;
14
+ username: string | undefined;
15
+ password: string | undefined;
10
16
  }>;
11
- export default class MongoDB implements DB {
17
+ export default class MongoDB implements DB<MongoClient> {
12
18
  #private;
13
19
  static config: typeof schema.input;
14
20
  constructor(config?: typeof schema.input);
21
+ get client(): MongoClient;
15
22
  close(): Promise<void>;
16
- get schema(): {
17
- create: (_as: As, _store: StoreSchema) => Promise<void>;
18
- delete: (name: string) => Promise<void>;
19
- };
23
+ get schema(): Schema;
20
24
  create<O extends Dict>(as: As, record: Dict): Promise<O>;
21
25
  read(as: As, args: {
22
26
  count: true;
@@ -1,20 +1,17 @@
1
1
  import typemap from "#typemap";
2
- import common from "@primate/core/db";
3
- import E from "@primate/core/db/error";
2
+ import base from "@primate/core/db";
3
+ import E from "@primate/core/db/errors";
4
4
  import assert from "@rcompat/assert";
5
5
  import is from "@rcompat/is";
6
- import { MongoClient, ObjectId } from "mongodb";
6
+ import { MongoClient } from "mongodb";
7
7
  import p from "pema";
8
8
  const schema = p({
9
- database: p.string,
10
9
  host: p.string.default("localhost"),
11
- password: p.string.optional(),
12
10
  port: p.uint.port().default(27017),
11
+ database: p.string,
13
12
  username: p.string.optional(),
13
+ password: p.string.optional(),
14
14
  });
15
- function is_object_id(x) {
16
- return x instanceof ObjectId;
17
- }
18
15
  function get_limit(limit) {
19
16
  return limit ?? 0; // 0 = no limit
20
17
  }
@@ -52,6 +49,73 @@ async function bind_value(key, value) {
52
49
  function unbind_value(key, value) {
53
50
  return typemap[key].unbind(value);
54
51
  }
52
+ function to_mongo_pk(field, pk) {
53
+ return field === pk ? "_id" : field;
54
+ }
55
+ async function bind(as, object) {
56
+ const pk = as.pk;
57
+ const out = {};
58
+ for (const [field, value] of Object.entries(object)) {
59
+ const mongo_field = to_mongo_pk(field, pk);
60
+ const datatype = as.types[field];
61
+ if (value === null) {
62
+ out[mongo_field] = null;
63
+ continue;
64
+ }
65
+ if (is.dict(value) && datatype !== "json") {
66
+ const ops = Object.entries(value);
67
+ if (ops.length === 0)
68
+ throw E.operator_empty(field);
69
+ for (const [op, op_value] of ops) {
70
+ const existing = (out[mongo_field] ?? {});
71
+ let next;
72
+ switch (op) {
73
+ case "$like":
74
+ next = { $regex: like_to_regex(String(op_value)) };
75
+ break;
76
+ case "$ilike":
77
+ next = { $regex: like_to_regex(String(op_value)), $options: "i" };
78
+ break;
79
+ case "$ne":
80
+ case "$gt":
81
+ case "$gte":
82
+ case "$lt":
83
+ case "$lte":
84
+ next = { [op]: await bind_value(datatype, op_value) };
85
+ break;
86
+ case "$after":
87
+ next = { $gt: await bind_value(datatype, op_value) };
88
+ break;
89
+ case "$before":
90
+ next = { $lt: await bind_value(datatype, op_value) };
91
+ break;
92
+ case "$in": {
93
+ const array = op_value;
94
+ const bound = await Promise.all(array.map(v => bind_value(datatype, v)));
95
+ next = { $in: bound };
96
+ break;
97
+ }
98
+ default:
99
+ throw E.operator_unknown(field, op);
100
+ }
101
+ out[mongo_field] = { ...existing, ...next };
102
+ }
103
+ continue;
104
+ }
105
+ if (field === pk) {
106
+ const type = as.types[pk];
107
+ if (base.is_uuid_type(type)) {
108
+ out._id = await bind_value(type, value);
109
+ }
110
+ else {
111
+ out._id = value;
112
+ }
113
+ continue;
114
+ }
115
+ out[mongo_field] = await bind_value(datatype, value);
116
+ }
117
+ return out;
118
+ }
55
119
  function like_to_regex(pattern) {
56
120
  return "^" + pattern
57
121
  .replace(/\\%/g, "<<PERCENT>>")
@@ -62,15 +126,37 @@ function like_to_regex(pattern) {
62
126
  .replace(/<<PERCENT>>/g, "%")
63
127
  .replace(/<<UNDERSCORE>>/g, "_") + "$";
64
128
  }
129
+ function columns_to_types(row) {
130
+ switch (row.type) {
131
+ case "string": return ["string", "url", "time", "i128", "u128"];
132
+ case "int": return ["i8", "u8", "i16", "u16", "i32", "u32"];
133
+ case "long": return ["i64"];
134
+ case "double": return ["f32", "f64"];
135
+ case "bool": return ["boolean"];
136
+ case "date": return ["datetime"];
137
+ case "binData": return row.name === "_id" ? base.UUID_TYPES : ["blob"];
138
+ case "decimal": return ["u64"];
139
+ case "objectId": return ["string"];
140
+ case "object": return ["json"];
141
+ case "array": return ["json"];
142
+ default: return [];
143
+ }
144
+ }
65
145
  export default class MongoDB {
66
146
  static config;
67
147
  #factory;
68
148
  #database;
69
149
  #client;
70
150
  constructor(config) {
71
- const { host, port, database } = schema.parse(config);
72
- const params = "directConnection=true&replicaSet=rs0";
73
- const client = new MongoClient(`mongodb://${host}:${port}?${params}`);
151
+ const { host, port, database, username, password, } = schema.parse(config);
152
+ const uri = new URL(`mongodb://${host}:${port}/${database}`);
153
+ uri.searchParams.set("directConnection", "true");
154
+ if (username !== undefined) {
155
+ uri.username = username;
156
+ uri.password = password ?? "";
157
+ uri.searchParams.set("authSource", database);
158
+ }
159
+ const client = new MongoClient(uri.toString());
74
160
  this.#database = database;
75
161
  this.#factory = async () => {
76
162
  await client.connect();
@@ -81,17 +167,69 @@ export default class MongoDB {
81
167
  this.#client ??= await this.#factory();
82
168
  return this.#client.db(this.#database).collection(name);
83
169
  }
170
+ get client() {
171
+ return this.#client;
172
+ }
84
173
  async close() {
85
- await this.#client?.close();
174
+ const client = this.#client;
175
+ this.#client = undefined;
176
+ await client?.close();
86
177
  }
87
178
  get schema() {
88
179
  return {
89
- create: async (_as, _store) => {
90
- // MongoDB is schemaless, noop
180
+ create: async (collection) => {
181
+ const client = this.#client ??= await this.#factory();
182
+ const db = client.db(this.#database);
183
+ // ignore if already exists
184
+ await db.createCollection(collection).catch(() => { });
185
+ },
186
+ delete: async (collection) => {
187
+ const client = this.#client ??= await this.#factory();
188
+ const db = client.db(this.#database);
189
+ // ignore if doesn't exist
190
+ await db.dropCollection(collection).catch(() => { });
91
191
  },
92
- delete: async (name) => {
192
+ introspect: async (name, pk) => {
193
+ const client = (this.#client ??= await this.#factory());
194
+ const db = client.db(this.#database);
195
+ const collections = await db.listCollections({ name }).toArray();
196
+ if (collections.length === 0)
197
+ return null;
93
198
  const collection = await this.#collection(name);
94
- await collection.drop().catch(() => { }); // ignore if doesn't exist
199
+ const rows = await collection.aggregate([
200
+ { $limit: 1 },
201
+ { $project: { fields: { $objectToArray: "$$ROOT" } } },
202
+ { $unwind: "$fields" },
203
+ { $project: { _id: 0, name: "$fields.k", type: { $type: "$fields.v" } } },
204
+ ]).toArray();
205
+ if (rows.length === 0)
206
+ return {};
207
+ const result = {};
208
+ for (const row of rows) {
209
+ if (pk !== undefined && row.name === "_id") {
210
+ if (pk !== null)
211
+ result[pk] = columns_to_types(row);
212
+ continue;
213
+ }
214
+ result[row.name] = columns_to_types(row);
215
+ }
216
+ return result;
217
+ },
218
+ alter: async (name, diff) => {
219
+ const client = this.#client ??= await this.#factory();
220
+ const db = client.db(this.#database);
221
+ const collections = await db.listCollections({ name }).toArray();
222
+ if (collections.length === 0)
223
+ throw E.table_not_found(name);
224
+ const collection = await this.#collection(name);
225
+ if (diff.rename.length > 0) {
226
+ const $rename = Object.fromEntries(diff.rename);
227
+ await collection.updateMany({}, { $rename });
228
+ }
229
+ if (diff.drop.length > 0) {
230
+ const $unset = Object.fromEntries(diff.drop.map(f => [f, ""]));
231
+ await collection.updateMany({}, { $unset });
232
+ }
95
233
  },
96
234
  };
97
235
  }
@@ -101,64 +239,6 @@ export default class MongoDB {
101
239
  #from_mongo_pk(field, pk) {
102
240
  return field === "_id" && pk !== null ? pk : field;
103
241
  }
104
- async #bind(as, object) {
105
- const pk = as.pk;
106
- const out = {};
107
- for (const [field, value] of Object.entries(object)) {
108
- const mongo_field = this.#to_mongo_pk(field, pk);
109
- const datatype = as.types[field];
110
- if (value === null) {
111
- out[mongo_field] = null;
112
- continue;
113
- }
114
- if (is.dict(value)) {
115
- const ops = Object.entries(value);
116
- if (ops.length === 0)
117
- throw E.operator_empty(field);
118
- for (const [op, op_value] of ops) {
119
- const existing = (out[mongo_field] ?? {});
120
- let next;
121
- switch (op) {
122
- case "$like":
123
- next = { $regex: like_to_regex(String(op_value)) };
124
- break;
125
- case "$ilike":
126
- next = { $regex: like_to_regex(String(op_value)), $options: "i" };
127
- break;
128
- case "$ne":
129
- case "$gt":
130
- case "$gte":
131
- case "$lt":
132
- case "$lte":
133
- next = { [op]: await bind_value(datatype, op_value) };
134
- break;
135
- case "$after":
136
- next = { $gt: await bind_value(datatype, op_value) };
137
- break;
138
- case "$before":
139
- next = { $lt: await bind_value(datatype, op_value) };
140
- break;
141
- default:
142
- throw E.operator_unknown(field, op);
143
- }
144
- out[mongo_field] = { ...existing, ...next };
145
- }
146
- continue;
147
- }
148
- if (field === pk) {
149
- const type = as.types[pk];
150
- if (type === "string" && ObjectId.isValid(value)) {
151
- out._id = new ObjectId(value);
152
- }
153
- else {
154
- out._id = value;
155
- }
156
- continue;
157
- }
158
- out[mongo_field] = await bind_value(datatype, value);
159
- }
160
- return out;
161
- }
162
242
  #unbind(as, doc) {
163
243
  const pk = as.pk;
164
244
  const out = {};
@@ -169,8 +249,10 @@ export default class MongoDB {
169
249
  continue;
170
250
  }
171
251
  if (field === "_id") {
172
- // handle ObjectId to string for PK, other keep as-is
173
- out[user_field] = is_object_id(value) ? value.toString() : value;
252
+ const type = as.types[user_field];
253
+ out[user_field] = base.is_uuid_type(type)
254
+ ? unbind_value(type, value)
255
+ : value;
174
256
  continue;
175
257
  }
176
258
  out[user_field] = unbind_value(datatype, value);
@@ -181,13 +263,13 @@ export default class MongoDB {
181
263
  const pk = as.pk;
182
264
  const type = as.types[pk];
183
265
  const collection = await this.#collection(as.table);
184
- if (type === "string")
185
- return new ObjectId();
266
+ if (base.is_uuid_type(type))
267
+ return base.generate_uuid(type);
186
268
  // for numeric types, find max and increment
187
269
  const pipeline = [{ $group: { _id: null, max: { $max: "$_id" } } }];
188
270
  const results = await collection.aggregate(pipeline).toArray();
189
271
  const max = results.length === 0 ? 0 : results[0].max ?? 0;
190
- if (common.BIGINT_STRING_TYPES.includes(type))
272
+ if (base.BIGINT_STRING_TYPES.includes(type))
191
273
  return BigInt(max) + 1n;
192
274
  return Number(max) + 1;
193
275
  }
@@ -201,15 +283,17 @@ export default class MongoDB {
201
283
  const type = as.types[pk];
202
284
  if (pk in record) {
203
285
  pk_value = record[pk];
204
- doc._id = type === "string" && ObjectId.isValid(pk_value)
205
- ? new ObjectId(pk_value)
286
+ doc._id = base.is_uuid_type(type)
287
+ ? await bind_value(type, pk_value)
206
288
  : pk_value;
207
289
  }
208
290
  else if (as.generate_pk !== false) {
209
291
  const generated = await this.#generate_pk(as);
210
- doc._id = generated;
211
292
  // convert to user-facing value
212
- pk_value = is_object_id(generated) ? generated.toString() : generated;
293
+ pk_value = generated;
294
+ doc._id = base.is_uuid_type(type)
295
+ ? await bind_value(type, generated)
296
+ : generated;
213
297
  }
214
298
  else {
215
299
  throw E.pk_required(pk);
@@ -232,37 +316,37 @@ export default class MongoDB {
232
316
  if (args.count === true)
233
317
  return this.#count(as, args.where);
234
318
  // relations always use phased approach
235
- if (common.withed(args))
319
+ if (base.withed(args))
236
320
  return this.#read_phased(as, args);
237
321
  return this.#read(as, args);
238
322
  }
239
323
  async #count(as, where) {
240
- const filter = await this.#bind(as, where);
324
+ const filter = await bind(as, where);
241
325
  const collection = await this.#collection(as.table);
242
326
  const count = await collection.countDocuments(filter);
243
327
  return count;
244
328
  }
245
329
  async #read(as, args) {
246
- const filter = await this.#bind(as, args.where);
330
+ const filter = await bind(as, args.where);
247
331
  const collection = await this.#collection(as.table);
248
332
  const projection = get_projection(as.pk, args.fields);
249
333
  const sort = get_sort(args.sort);
250
334
  const limit = get_limit(args.limit);
251
335
  const options = { useBigInt64: true };
252
- if (projection)
336
+ if (is.defined(projection))
253
337
  options.projection = projection;
254
- if (sort)
338
+ if (is.defined(sort))
255
339
  options.sort = sort;
256
- const docs = await collection
257
- .find(filter, options)
258
- .limit(limit)
259
- .toArray();
340
+ const cursor = collection.find(filter, options).limit(limit);
341
+ if (is.defined(args.offset))
342
+ cursor.skip(args.offset);
343
+ const docs = await cursor.toArray();
260
344
  return docs.map(doc => this.#unbind(as, doc));
261
345
  }
262
346
  async #read_phased(as, args) {
263
- const fields = common.expand(as, args.fields, args.with);
347
+ const fields = base.expand(as, args.fields, args.with);
264
348
  const rows = await this.#read(as, { ...args, fields });
265
- const out = rows.map(row => common.project(row, args.fields));
349
+ const out = rows.map(row => base.project(row, args.fields));
266
350
  for (const [name, relation] of Object.entries(args.with)) {
267
351
  await this.#attach_relation(as, { rows, out, name, relation });
268
352
  }
@@ -298,22 +382,22 @@ export default class MongoDB {
298
382
  }
299
383
  const rows = grouped.get(join_value) ?? [];
300
384
  args.out[i][args.name] = is_many
301
- ? rows.map(r => common.project(r, relation.fields))
302
- : rows[0] ? common.project(rows[0], relation.fields) : null;
385
+ ? rows.map(r => base.project(r, relation.fields))
386
+ : rows[0] ? base.project(rows[0], relation.fields) : null;
303
387
  }
304
388
  }
305
389
  async #load_related(args) {
306
390
  // build filter with $in for join values
307
- const filter = await this.#bind(args.as, args.where);
391
+ const filter = await bind(args.as, args.where);
308
392
  const by_field = this.#to_mongo_pk(args.by, args.as.pk);
309
393
  // convert join values to ObjectId if needed
310
- const pk_type = args.as.types[args.as.pk];
311
- const in_values = args.by === args.as.pk && pk_type === "string"
312
- ? args.join_values.map(v => ObjectId.isValid(v) ? new ObjectId(v) : v)
394
+ const fk_type = args.as.types[args.by];
395
+ const in_values = base.is_uuid_type(fk_type)
396
+ ? await Promise.all(args.join_values.map(v => bind_value(fk_type, v)))
313
397
  : args.join_values;
314
398
  filter[by_field] = { $in: in_values };
315
399
  const collection = await this.#collection(args.as.table);
316
- const fields_with_by = common.fields(args.fields, args.by);
400
+ const fields_with_by = base.fields(args.fields, args.by);
317
401
  const projection = get_projection(args.as.pk, fields_with_by);
318
402
  const sort = get_sort(args.sort);
319
403
  const options = { useBigInt64: true };
@@ -344,7 +428,7 @@ export default class MongoDB {
344
428
  async update(as, args) {
345
429
  assert.nonempty(args.set);
346
430
  assert.dict(args.where);
347
- const filter = await this.#bind(as, args.where);
431
+ const filter = await bind(as, args.where);
348
432
  const collection = await this.#collection(as.table);
349
433
  const $set = {};
350
434
  const $unset = {};
@@ -367,7 +451,7 @@ export default class MongoDB {
367
451
  }
368
452
  async delete(as, args) {
369
453
  assert.nonempty(args.where);
370
- const filter = await this.#bind(as, args.where);
454
+ const filter = await bind(as, args.where);
371
455
  const collection = await this.#collection(as.table);
372
456
  const result = await collection.deleteMany(filter);
373
457
  return result.deletedCount;
@@ -1,4 +1,4 @@
1
- import { Binary, Decimal128 } from "mongodb";
1
+ import { Binary, Decimal128, UUID } from "mongodb";
2
2
  function identity(column) {
3
3
  return {
4
4
  bind: value => value,
@@ -45,6 +45,7 @@ const typemap = {
45
45
  i32: identity("INT"),
46
46
  i64: identity("LONG"),
47
47
  i8: identity("INT"),
48
+ json: identity("JSON"),
48
49
  string: identity("STRING"),
49
50
  time: identity("TIME"),
50
51
  u128: {
@@ -69,6 +70,21 @@ const typemap = {
69
70
  return new URL(value);
70
71
  },
71
72
  },
73
+ uuid: {
74
+ bind: (value) => new UUID(value),
75
+ column: "UUID",
76
+ unbind: (value) => value.toString(),
77
+ },
78
+ uuid_v4: {
79
+ bind: (value) => new UUID(value),
80
+ column: "UUID",
81
+ unbind: (value) => value.toString(),
82
+ },
83
+ uuid_v7: {
84
+ bind: (value) => new UUID(value),
85
+ column: "UUID",
86
+ unbind: (value) => value.toString(),
87
+ },
72
88
  };
73
89
  export default typemap;
74
90
  //# sourceMappingURL=typemap.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primate/mongodb",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MongoDB databases for Primate",
5
5
  "homepage": "https://primate.run/docs/database/mongodb",
6
6
  "bugs": "https://github.com/primate-run/primate/issues",
@@ -17,28 +17,28 @@
17
17
  "!/**/*.spec.*"
18
18
  ],
19
19
  "dependencies": {
20
- "@rcompat/assert": "^0.6.0",
21
- "@rcompat/dict": "^0.3.1",
22
- "@rcompat/is": "^0.4.3",
23
- "mongodb": "^6.17.0",
24
- "@primate/core": "^0.5.0",
25
- "pema": "0.5.0"
20
+ "@rcompat/assert": "^0.13.0",
21
+ "@rcompat/dict": "^0.10.0",
22
+ "@rcompat/is": "^0.11.0",
23
+ "mongodb": "^7.2.0",
24
+ "@primate/core": "^0.7.0",
25
+ "pema": "0.7.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@rcompat/type": "^0.9.0"
28
+ "@rcompat/type": "^0.17.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "primate": "^0.36.0"
31
+ "primate": "^0.38.0"
32
32
  },
33
33
  "imports": {
34
34
  "#*": {
35
- "apekit": "./src/private/*.ts",
35
+ "@primate/source": "./src/private/*.ts",
36
36
  "default": "./lib/private/*.js"
37
37
  }
38
38
  },
39
39
  "exports": {
40
40
  ".": {
41
- "apekit": "./src/public/index.ts",
41
+ "@primate/source": "./src/public/index.ts",
42
42
  "default": "./lib/public/index.js"
43
43
  }
44
44
  },
@@ -46,7 +46,7 @@
46
46
  "build": "npm run clean && tsc",
47
47
  "clean": "rm -rf ./lib",
48
48
  "lint": "eslint .",
49
- "test": "npm run build && npx proby",
50
- "start:db": "sudo mongod --dbpath /var/lib/mongodb"
49
+ "test": "npm run db && npx proby",
50
+ "db": "scripts/db.sh"
51
51
  }
52
52
  }