@frogfish/k2db 2.0.3 → 2.0.7
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 +18 -0
- package/dist/README.md +18 -0
- package/dist/data.d.ts +3 -1
- package/dist/data.js +2 -0
- package/dist/db.d.ts +19 -0
- package/dist/db.js +170 -10
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -295,3 +295,21 @@ Returns by method
|
|
|
295
295
|
- `setSchema(collection, zodSchema, { mode }?)`: `void`
|
|
296
296
|
- `clearSchema(collection)`: `void`
|
|
297
297
|
- `clearSchemas()`: `void`
|
|
298
|
+
|
|
299
|
+
## UUID
|
|
300
|
+
|
|
301
|
+
_uuid = Crockford Base32 encoded UUID V7, Uppercase, with hyphens
|
|
302
|
+
|
|
303
|
+
0J4F2-H6M8Q-7RX4V-9D3TN-8K2WZ
|
|
304
|
+
|
|
305
|
+
// Canonical uppercase form with hyphens
|
|
306
|
+
const CROCKFORD_ID_REGEX = /^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{6}$/;
|
|
307
|
+
|
|
308
|
+
// Example usage:
|
|
309
|
+
const id = "0J4F2-H6M8Q-7RX4V-9D3TN-8K2WZ";
|
|
310
|
+
console.log(CROCKFORD_ID_REGEX.test(id)); // true
|
|
311
|
+
|
|
312
|
+
Usage examples:
|
|
313
|
+
|
|
314
|
+
import { isK2ID, K2DB } from '@frogfish/k2db'
|
|
315
|
+
isK2ID('01HZY2AB-3JKM-4NPQ-5RST-6VWXYZ')
|
package/dist/README.md
CHANGED
|
@@ -295,3 +295,21 @@ Returns by method
|
|
|
295
295
|
- `setSchema(collection, zodSchema, { mode }?)`: `void`
|
|
296
296
|
- `clearSchema(collection)`: `void`
|
|
297
297
|
- `clearSchemas()`: `void`
|
|
298
|
+
|
|
299
|
+
## UUID
|
|
300
|
+
|
|
301
|
+
_uuid = Crockford Base32 encoded UUID V7, Uppercase, with hyphens
|
|
302
|
+
|
|
303
|
+
0J4F2-H6M8Q-7RX4V-9D3TN-8K2WZ
|
|
304
|
+
|
|
305
|
+
// Canonical uppercase form with hyphens
|
|
306
|
+
const CROCKFORD_ID_REGEX = /^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{6}$/;
|
|
307
|
+
|
|
308
|
+
// Example usage:
|
|
309
|
+
const id = "0J4F2-H6M8Q-7RX4V-9D3TN-8K2WZ";
|
|
310
|
+
console.log(CROCKFORD_ID_REGEX.test(id)); // true
|
|
311
|
+
|
|
312
|
+
Usage examples:
|
|
313
|
+
|
|
314
|
+
import { isK2ID, K2DB } from '@frogfish/k2db'
|
|
315
|
+
isK2ID('01HZY2AB-3JKM-4NPQ-5RST-6VWXYZ')
|
package/dist/data.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { K2DB } from "./db.js";
|
|
2
|
+
import type { BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, VersionedUpdateResult, VersionInfo } from "./db.js";
|
|
2
3
|
export declare class K2Data {
|
|
3
4
|
private db;
|
|
4
5
|
private owner;
|
|
@@ -100,4 +101,5 @@ export declare class K2Data {
|
|
|
100
101
|
isHealthy(): Promise<boolean>;
|
|
101
102
|
}
|
|
102
103
|
export { K2DB } from "./db.js";
|
|
104
|
+
export declare const isK2ID: (id: string) => boolean;
|
|
103
105
|
export type { DatabaseConfig, BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, VersionedUpdateResult, VersionInfo, } from "./db.js";
|
package/dist/data.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { K2DB } from "./db.js";
|
|
1
2
|
export class K2Data {
|
|
2
3
|
db;
|
|
3
4
|
owner;
|
|
@@ -148,3 +149,4 @@ export class K2Data {
|
|
|
148
149
|
}
|
|
149
150
|
// Re-export K2DB (runtime) and types from the root entry
|
|
150
151
|
export { K2DB } from "./db.js";
|
|
152
|
+
export const isK2ID = (id) => K2DB.isK2ID(id);
|
package/dist/db.d.ts
CHANGED
|
@@ -47,6 +47,9 @@ export interface DropResult {
|
|
|
47
47
|
export interface PurgeResult {
|
|
48
48
|
id: string;
|
|
49
49
|
}
|
|
50
|
+
export interface PurgeManyResult {
|
|
51
|
+
purged: number;
|
|
52
|
+
}
|
|
50
53
|
export interface VersionedUpdateResult {
|
|
51
54
|
updated: number;
|
|
52
55
|
versionSaved: number;
|
|
@@ -150,6 +153,14 @@ export declare class K2DB {
|
|
|
150
153
|
* @param id - UUID of the document.
|
|
151
154
|
*/
|
|
152
155
|
purge(collectionName: string, id: string): Promise<PurgeResult>;
|
|
156
|
+
/**
|
|
157
|
+
* Permanently deletes all documents that are soft-deleted and whose _updated
|
|
158
|
+
* timestamp is older than the provided threshold (in milliseconds ago).
|
|
159
|
+
* @param collectionName - Name of the collection.
|
|
160
|
+
* @param olderThanMs - Age threshold in milliseconds; documents with
|
|
161
|
+
* `_updated <= (Date.now() - olderThanMs)` will be purged.
|
|
162
|
+
*/
|
|
163
|
+
purgeDeletedOlderThan(collectionName: string, olderThanMs: number): Promise<PurgeManyResult>;
|
|
153
164
|
/**
|
|
154
165
|
* Restores a soft-deleted document.
|
|
155
166
|
* @param collectionName - Name of the collection.
|
|
@@ -172,8 +183,16 @@ export declare class K2DB {
|
|
|
172
183
|
* @param criteria - Aggregation stage criteria.
|
|
173
184
|
*/
|
|
174
185
|
private static sanitiseCriteria;
|
|
186
|
+
/** Recursively uppercases any values for fields named `_uuid` within a query object. */
|
|
187
|
+
private static normalizeCriteriaIds;
|
|
188
|
+
/** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
189
|
+
private static normalizeUuidField;
|
|
175
190
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
176
191
|
private static stripReservedFields;
|
|
192
|
+
/** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
|
|
193
|
+
static isK2ID(id: string): boolean;
|
|
194
|
+
/** Uppercase incoming IDs for case-insensitive lookups. */
|
|
195
|
+
private static normalizeId;
|
|
177
196
|
/**
|
|
178
197
|
* Run an async DB operation with timing, slow logging, and hooks.
|
|
179
198
|
*/
|
package/dist/db.js
CHANGED
|
@@ -1,10 +1,62 @@
|
|
|
1
1
|
// src/db.ts
|
|
2
2
|
import { K2Error, ServiceError } from "@frogfish/k2error"; // Keep the existing error structure
|
|
3
3
|
import { MongoClient, } from "mongodb";
|
|
4
|
-
import {
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
5
|
import debugLib from "debug";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
const debug = debugLib("k2:db");
|
|
8
|
+
// Crockford Base32 alphabet (no I, L, O, U)
|
|
9
|
+
const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
10
|
+
/**
|
|
11
|
+
* Generates a UUIDv7 (time-ordered) and encodes it as Crockford Base32 with hyphens.
|
|
12
|
+
* Format: 26 base32 chars grouped as 8-4-4-4-6 (total 26)
|
|
13
|
+
*/
|
|
14
|
+
function uuidv7Base32Hyphenated() {
|
|
15
|
+
// 1) Build UUIDv7 bytes
|
|
16
|
+
// Layout per RFC: time_low(32) | time_mid(16) | time_hi_and_version(16) | clock_seq(16) | node(48)
|
|
17
|
+
// Encode 60-bit ms timestamp across time_* fields, version 7, RFC4122 variant in clock_seq_hi.
|
|
18
|
+
const ts = BigInt(Date.now()); // milliseconds
|
|
19
|
+
const timeLow = Number((ts >> 28n) & 0xffffffffn);
|
|
20
|
+
const timeMid = Number((ts >> 12n) & 0xffffn);
|
|
21
|
+
const timeHi = Number(ts & 0xfffn); // lower 12 bits
|
|
22
|
+
const bytes = new Uint8Array(16);
|
|
23
|
+
// time_low (big-endian)
|
|
24
|
+
bytes[0] = (timeLow >>> 24) & 0xff;
|
|
25
|
+
bytes[1] = (timeLow >>> 16) & 0xff;
|
|
26
|
+
bytes[2] = (timeLow >>> 8) & 0xff;
|
|
27
|
+
bytes[3] = timeLow & 0xff;
|
|
28
|
+
// time_mid (big-endian)
|
|
29
|
+
bytes[4] = (timeMid >>> 8) & 0xff;
|
|
30
|
+
bytes[5] = timeMid & 0xff;
|
|
31
|
+
// time_high_and_version: version 7 in high nibble + top 4 bits of timeHi
|
|
32
|
+
bytes[6] = 0x70 | ((timeHi >>> 8) & 0x0f); // 0x7- version
|
|
33
|
+
bytes[7] = timeHi & 0xff;
|
|
34
|
+
// clock_seq + node: 8 random bytes; set RFC4122 variant (10xxxxxx)
|
|
35
|
+
const rnd = randomBytes(8);
|
|
36
|
+
bytes.set(rnd, 8);
|
|
37
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // set variant 10xxxxxx
|
|
38
|
+
// 2) Encode as Crockford Base32 (26 chars). 128 bits -> 26*5 bits (pad 2 high bits)
|
|
39
|
+
let value = 0n;
|
|
40
|
+
for (let i = 0; i < 16; i++) {
|
|
41
|
+
value = (value << 8n) | BigInt(bytes[i]);
|
|
42
|
+
}
|
|
43
|
+
value <<= 2n; // pad to 130 bits so we can take 26 groups cleanly
|
|
44
|
+
let encoded = "";
|
|
45
|
+
for (let i = 25; i >= 0; i--) {
|
|
46
|
+
const idx = Number((value >> BigInt(i * 5)) & 0x1fn);
|
|
47
|
+
encoded += CROCKFORD32[idx];
|
|
48
|
+
}
|
|
49
|
+
// 3) Insert hyphens in groups: 8-4-4-4-6
|
|
50
|
+
return (encoded.slice(0, 8) +
|
|
51
|
+
"-" +
|
|
52
|
+
encoded.slice(8, 12) +
|
|
53
|
+
"-" +
|
|
54
|
+
encoded.slice(12, 16) +
|
|
55
|
+
"-" +
|
|
56
|
+
encoded.slice(16, 20) +
|
|
57
|
+
"-" +
|
|
58
|
+
encoded.slice(20));
|
|
59
|
+
}
|
|
8
60
|
export class K2DB {
|
|
9
61
|
conf;
|
|
10
62
|
db;
|
|
@@ -110,8 +162,9 @@ export class K2DB {
|
|
|
110
162
|
}
|
|
111
163
|
}
|
|
112
164
|
async get(collectionName, uuid) {
|
|
165
|
+
const id = K2DB.normalizeId(uuid);
|
|
113
166
|
const res = await this.findOne(collectionName, {
|
|
114
|
-
_uuid:
|
|
167
|
+
_uuid: id,
|
|
115
168
|
_deleted: { $ne: true },
|
|
116
169
|
});
|
|
117
170
|
if (!res) {
|
|
@@ -130,8 +183,9 @@ export class K2DB {
|
|
|
130
183
|
const collection = await this.getCollection(collectionName);
|
|
131
184
|
const projection = {};
|
|
132
185
|
// Exclude soft-deleted documents by default unless caller specifies otherwise
|
|
186
|
+
const normalizedCriteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
133
187
|
const query = {
|
|
134
|
-
...
|
|
188
|
+
...normalizedCriteria,
|
|
135
189
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
136
190
|
? {}
|
|
137
191
|
: { _deleted: { $ne: true } }),
|
|
@@ -164,7 +218,8 @@ export class K2DB {
|
|
|
164
218
|
async find(collectionName, filter, params = {}, skip = 0, limit = 100) {
|
|
165
219
|
const collection = await this.getCollection(collectionName);
|
|
166
220
|
// Ensure filter is valid, defaulting to an empty object
|
|
167
|
-
|
|
221
|
+
let criteria = { ...(filter || {}) };
|
|
222
|
+
criteria = K2DB.normalizeCriteriaIds(criteria);
|
|
168
223
|
// Handle the _deleted field if params specify not to include deleted documents
|
|
169
224
|
if (!params?.includeDeleted && !Object.prototype.hasOwnProperty.call(criteria, "_deleted")) {
|
|
170
225
|
if (params?.deleted === true) {
|
|
@@ -358,8 +413,8 @@ export class K2DB {
|
|
|
358
413
|
}
|
|
359
414
|
const collection = await this.getCollection(collectionName);
|
|
360
415
|
const timestamp = Date.now();
|
|
361
|
-
// Generate a new
|
|
362
|
-
const newUuid =
|
|
416
|
+
// Generate a new UUIDv7 encoded as Crockford Base32 with hyphens
|
|
417
|
+
const newUuid = uuidv7Base32Hyphenated();
|
|
363
418
|
// Remove reserved fields from user data, then validate/transform via schema if present
|
|
364
419
|
const safeData = K2DB.stripReservedFields(data);
|
|
365
420
|
const validated = this.applySchema(collectionName, safeData, /*partial*/ false);
|
|
@@ -398,9 +453,20 @@ export class K2DB {
|
|
|
398
453
|
this.validateCollectionName(collectionName);
|
|
399
454
|
const collection = await this.getCollection(collectionName);
|
|
400
455
|
debug(`Updating ${collectionName} with criteria: ${JSON.stringify(criteria)}`);
|
|
456
|
+
// Preserve intent to set _deleted during internal soft-delete operations.
|
|
457
|
+
// stripReservedFields removes underscore-prefixed keys (by design) to protect
|
|
458
|
+
// internal fields from user updates. However, deleteAll() legitimately passes
|
|
459
|
+
// {_deleted: true}. Capture and restore it here.
|
|
460
|
+
const deletedFlag = Object.prototype.hasOwnProperty.call(values, "_deleted")
|
|
461
|
+
? values._deleted
|
|
462
|
+
: undefined;
|
|
401
463
|
values = K2DB.stripReservedFields(values);
|
|
402
464
|
values = this.applySchema(collectionName, values, /*partial*/ true);
|
|
403
465
|
values._updated = Date.now();
|
|
466
|
+
if (deletedFlag !== undefined) {
|
|
467
|
+
values._deleted = deletedFlag;
|
|
468
|
+
}
|
|
469
|
+
criteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
404
470
|
criteria = {
|
|
405
471
|
...criteria,
|
|
406
472
|
_deleted: { $ne: true },
|
|
@@ -424,6 +490,7 @@ export class K2DB {
|
|
|
424
490
|
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
425
491
|
*/
|
|
426
492
|
async update(collectionName, id, data, replace = false) {
|
|
493
|
+
id = K2DB.normalizeId(id);
|
|
427
494
|
this.validateCollectionName(collectionName);
|
|
428
495
|
const collection = await this.getCollection(collectionName);
|
|
429
496
|
data = K2DB.stripReservedFields(data);
|
|
@@ -489,6 +556,7 @@ export class K2DB {
|
|
|
489
556
|
* @param id - UUID of the document.
|
|
490
557
|
*/
|
|
491
558
|
async delete(collectionName, id) {
|
|
559
|
+
id = K2DB.normalizeId(id);
|
|
492
560
|
try {
|
|
493
561
|
// Call deleteAll to soft delete the document by UUID
|
|
494
562
|
const result = await this.deleteAll(collectionName, { _uuid: id });
|
|
@@ -507,6 +575,10 @@ export class K2DB {
|
|
|
507
575
|
}
|
|
508
576
|
}
|
|
509
577
|
catch (err) {
|
|
578
|
+
// Preserve existing K2Error classifications (e.g., NOT_FOUND)
|
|
579
|
+
if (err instanceof K2Error) {
|
|
580
|
+
throw err;
|
|
581
|
+
}
|
|
510
582
|
throw new K2Error(ServiceError.SYSTEM_ERROR, "Error removing object from collection", "sys_mdb_remove_upd", this.normalizeError(err));
|
|
511
583
|
}
|
|
512
584
|
}
|
|
@@ -516,6 +588,7 @@ export class K2DB {
|
|
|
516
588
|
* @param id - UUID of the document.
|
|
517
589
|
*/
|
|
518
590
|
async purge(collectionName, id) {
|
|
591
|
+
id = K2DB.normalizeId(id);
|
|
519
592
|
const collection = await this.getCollection(collectionName);
|
|
520
593
|
try {
|
|
521
594
|
const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
|
|
@@ -535,6 +608,31 @@ export class K2DB {
|
|
|
535
608
|
throw new K2Error(ServiceError.SYSTEM_ERROR, `Error purging item with id: ${id}`, "sys_mdb_pg", this.normalizeError(err));
|
|
536
609
|
}
|
|
537
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* Permanently deletes all documents that are soft-deleted and whose _updated
|
|
613
|
+
* timestamp is older than the provided threshold (in milliseconds ago).
|
|
614
|
+
* @param collectionName - Name of the collection.
|
|
615
|
+
* @param olderThanMs - Age threshold in milliseconds; documents with
|
|
616
|
+
* `_updated <= (Date.now() - olderThanMs)` will be purged.
|
|
617
|
+
*/
|
|
618
|
+
async purgeDeletedOlderThan(collectionName, olderThanMs) {
|
|
619
|
+
this.validateCollectionName(collectionName);
|
|
620
|
+
if (typeof olderThanMs !== 'number' || !isFinite(olderThanMs) || olderThanMs < 0) {
|
|
621
|
+
throw new K2Error(ServiceError.BAD_REQUEST, 'olderThanMs must be a non-negative number', 'sys_mdb_purge_older_invalid');
|
|
622
|
+
}
|
|
623
|
+
const collection = await this.getCollection(collectionName);
|
|
624
|
+
const cutoff = Date.now() - olderThanMs;
|
|
625
|
+
try {
|
|
626
|
+
const res = await this.runTimed('deleteMany', { collectionName, olderThanMs, cutoff }, async () => await collection.deleteMany({
|
|
627
|
+
_deleted: true,
|
|
628
|
+
_updated: { $lte: cutoff },
|
|
629
|
+
}));
|
|
630
|
+
return { purged: res.deletedCount ?? 0 };
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
throw new K2Error(ServiceError.SYSTEM_ERROR, 'Error purging deleted items by age', 'sys_mdb_purge_older', this.normalizeError(err));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
538
636
|
/**
|
|
539
637
|
* Restores a soft-deleted document.
|
|
540
638
|
* @param collectionName - Name of the collection.
|
|
@@ -542,10 +640,12 @@ export class K2DB {
|
|
|
542
640
|
*/
|
|
543
641
|
async restore(collectionName, criteria) {
|
|
544
642
|
const collection = await this.getCollection(collectionName);
|
|
545
|
-
const
|
|
643
|
+
const crit = K2DB.normalizeCriteriaIds(criteria || {});
|
|
644
|
+
const query = { ...crit, _deleted: true };
|
|
546
645
|
try {
|
|
547
646
|
const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
|
|
548
|
-
|
|
647
|
+
// Restoring is a data change: flip _deleted and bump _updated
|
|
648
|
+
$set: { _deleted: false, _updated: Date.now() },
|
|
549
649
|
}));
|
|
550
650
|
return { status: "restored", modified: res.modifiedCount };
|
|
551
651
|
}
|
|
@@ -561,8 +661,9 @@ export class K2DB {
|
|
|
561
661
|
async count(collectionName, criteria) {
|
|
562
662
|
const collection = await this.getCollection(collectionName);
|
|
563
663
|
try {
|
|
664
|
+
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
564
665
|
const query = {
|
|
565
|
-
...
|
|
666
|
+
...norm,
|
|
566
667
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
567
668
|
? {}
|
|
568
669
|
: { _deleted: { $ne: true } }),
|
|
@@ -594,6 +695,8 @@ export class K2DB {
|
|
|
594
695
|
*/
|
|
595
696
|
static sanitiseCriteria(criteria) {
|
|
596
697
|
if (criteria.$match) {
|
|
698
|
+
// Normalize any _uuid values in the match object to uppercase
|
|
699
|
+
criteria.$match = K2DB.normalizeCriteriaIds(criteria.$match);
|
|
597
700
|
for (const key of Object.keys(criteria.$match)) {
|
|
598
701
|
if (typeof criteria.$match[key] !== "string") {
|
|
599
702
|
criteria.$match[key] = K2DB.sanitiseCriteria({
|
|
@@ -609,6 +712,45 @@ export class K2DB {
|
|
|
609
712
|
}
|
|
610
713
|
return criteria;
|
|
611
714
|
}
|
|
715
|
+
/** Recursively uppercases any values for fields named `_uuid` within a query object. */
|
|
716
|
+
static normalizeCriteriaIds(obj) {
|
|
717
|
+
if (!obj || typeof obj !== "object")
|
|
718
|
+
return obj;
|
|
719
|
+
if (Array.isArray(obj))
|
|
720
|
+
return obj.map((v) => K2DB.normalizeCriteriaIds(v));
|
|
721
|
+
const out = Array.isArray(obj) ? [] : { ...obj };
|
|
722
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
723
|
+
if (k === "_uuid") {
|
|
724
|
+
out[k] = K2DB.normalizeUuidField(v);
|
|
725
|
+
}
|
|
726
|
+
else if (v && typeof v === "object") {
|
|
727
|
+
out[k] = K2DB.normalizeCriteriaIds(v);
|
|
728
|
+
}
|
|
729
|
+
else if (Array.isArray(v)) {
|
|
730
|
+
out[k] = v.map((x) => K2DB.normalizeCriteriaIds(x));
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
out[k] = v;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
/** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
739
|
+
static normalizeUuidField(val) {
|
|
740
|
+
if (typeof val === "string")
|
|
741
|
+
return val.toUpperCase();
|
|
742
|
+
if (Array.isArray(val))
|
|
743
|
+
return val.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
|
|
744
|
+
if (val && typeof val === "object") {
|
|
745
|
+
const out = { ...val };
|
|
746
|
+
for (const op of ["$in", "$nin", "$eq", "$ne", "$all"]) {
|
|
747
|
+
if (op in out)
|
|
748
|
+
out[op] = K2DB.normalizeUuidField(out[op]);
|
|
749
|
+
}
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
return val;
|
|
753
|
+
}
|
|
612
754
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
613
755
|
static stripReservedFields(obj) {
|
|
614
756
|
const out = {};
|
|
@@ -618,6 +760,18 @@ export class K2DB {
|
|
|
618
760
|
}
|
|
619
761
|
return out;
|
|
620
762
|
}
|
|
763
|
+
/** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
|
|
764
|
+
static isK2ID(id) {
|
|
765
|
+
if (typeof id !== "string")
|
|
766
|
+
return false;
|
|
767
|
+
const s = id.trim().toUpperCase();
|
|
768
|
+
const CROCK_RE = /^[0-9A-HJKMNPQRSTVWXYZ]{8}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{4}-[0-9A-HJKMNPQRSTVWXYZ]{6}$/;
|
|
769
|
+
return CROCK_RE.test(s);
|
|
770
|
+
}
|
|
771
|
+
/** Uppercase incoming IDs for case-insensitive lookups. */
|
|
772
|
+
static normalizeId(id) {
|
|
773
|
+
return id.toUpperCase();
|
|
774
|
+
}
|
|
621
775
|
/**
|
|
622
776
|
* Run an async DB operation with timing, slow logging, and hooks.
|
|
623
777
|
*/
|
|
@@ -656,7 +810,9 @@ export class K2DB {
|
|
|
656
810
|
const { uuidUnique = false, uuidPartialUnique = true, ownerIndex = true, deletedIndex = true, } = opts;
|
|
657
811
|
const collection = await this.getCollection(collectionName);
|
|
658
812
|
if (uuidPartialUnique) {
|
|
659
|
-
|
|
813
|
+
// Use a compound unique index to ensure at most one non-deleted document per _uuid
|
|
814
|
+
// without relying on partialFilterExpression (which may be limited in some environments).
|
|
815
|
+
await collection.createIndex({ _uuid: 1, _deleted: 1 }, { unique: true });
|
|
660
816
|
}
|
|
661
817
|
else if (uuidUnique) {
|
|
662
818
|
await collection.createIndex({ _uuid: 1 }, { unique: true });
|
|
@@ -820,6 +976,7 @@ export class K2DB {
|
|
|
820
976
|
}
|
|
821
977
|
/** Compute the next version number for a document. */
|
|
822
978
|
async nextVersion(collectionName, id) {
|
|
979
|
+
id = K2DB.normalizeId(id);
|
|
823
980
|
const hc = await this.getHistoryCollection(collectionName);
|
|
824
981
|
const last = await hc
|
|
825
982
|
.find({ _uuid: id })
|
|
@@ -846,6 +1003,7 @@ export class K2DB {
|
|
|
846
1003
|
* If maxVersions is provided, prunes oldest snapshots beyond that number.
|
|
847
1004
|
*/
|
|
848
1005
|
async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
|
|
1006
|
+
id = K2DB.normalizeId(id);
|
|
849
1007
|
// Get current doc (excludes deleted) and snapshot it
|
|
850
1008
|
const current = await this.get(collectionName, id);
|
|
851
1009
|
await this.ensureHistoryIndexes(collectionName);
|
|
@@ -874,6 +1032,7 @@ export class K2DB {
|
|
|
874
1032
|
}
|
|
875
1033
|
/** List versions (latest first). */
|
|
876
1034
|
async listVersions(collectionName, id, skip = 0, limit = 20) {
|
|
1035
|
+
id = K2DB.normalizeId(id);
|
|
877
1036
|
const hc = await this.getHistoryCollection(collectionName);
|
|
878
1037
|
const rows = await hc
|
|
879
1038
|
.find({ _uuid: id })
|
|
@@ -886,6 +1045,7 @@ export class K2DB {
|
|
|
886
1045
|
}
|
|
887
1046
|
/** Revert the current document to a specific historical version (preserves metadata). */
|
|
888
1047
|
async revertToVersion(collectionName, id, version) {
|
|
1048
|
+
id = K2DB.normalizeId(id);
|
|
889
1049
|
const hc = await this.getHistoryCollection(collectionName);
|
|
890
1050
|
const row = await hc.findOne({ _uuid: id, _v: version });
|
|
891
1051
|
if (!row) {
|
package/dist/package.json
CHANGED