@frogfish/k2db 2.0.6 → 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 +8 -0
- package/dist/db.js +126 -8
- 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
|
@@ -183,8 +183,16 @@ export declare class K2DB {
|
|
|
183
183
|
* @param criteria - Aggregation stage criteria.
|
|
184
184
|
*/
|
|
185
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;
|
|
186
190
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
187
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;
|
|
188
196
|
/**
|
|
189
197
|
* Run an async DB operation with timing, slow logging, and hooks.
|
|
190
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);
|
|
@@ -411,6 +466,7 @@ export class K2DB {
|
|
|
411
466
|
if (deletedFlag !== undefined) {
|
|
412
467
|
values._deleted = deletedFlag;
|
|
413
468
|
}
|
|
469
|
+
criteria = K2DB.normalizeCriteriaIds(criteria || {});
|
|
414
470
|
criteria = {
|
|
415
471
|
...criteria,
|
|
416
472
|
_deleted: { $ne: true },
|
|
@@ -434,6 +490,7 @@ export class K2DB {
|
|
|
434
490
|
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
435
491
|
*/
|
|
436
492
|
async update(collectionName, id, data, replace = false) {
|
|
493
|
+
id = K2DB.normalizeId(id);
|
|
437
494
|
this.validateCollectionName(collectionName);
|
|
438
495
|
const collection = await this.getCollection(collectionName);
|
|
439
496
|
data = K2DB.stripReservedFields(data);
|
|
@@ -499,6 +556,7 @@ export class K2DB {
|
|
|
499
556
|
* @param id - UUID of the document.
|
|
500
557
|
*/
|
|
501
558
|
async delete(collectionName, id) {
|
|
559
|
+
id = K2DB.normalizeId(id);
|
|
502
560
|
try {
|
|
503
561
|
// Call deleteAll to soft delete the document by UUID
|
|
504
562
|
const result = await this.deleteAll(collectionName, { _uuid: id });
|
|
@@ -530,6 +588,7 @@ export class K2DB {
|
|
|
530
588
|
* @param id - UUID of the document.
|
|
531
589
|
*/
|
|
532
590
|
async purge(collectionName, id) {
|
|
591
|
+
id = K2DB.normalizeId(id);
|
|
533
592
|
const collection = await this.getCollection(collectionName);
|
|
534
593
|
try {
|
|
535
594
|
const item = await this.runTimed("findOne", { collectionName, _uuid: id, _deleted: true }, async () => await collection.findOne({
|
|
@@ -581,7 +640,8 @@ export class K2DB {
|
|
|
581
640
|
*/
|
|
582
641
|
async restore(collectionName, criteria) {
|
|
583
642
|
const collection = await this.getCollection(collectionName);
|
|
584
|
-
const
|
|
643
|
+
const crit = K2DB.normalizeCriteriaIds(criteria || {});
|
|
644
|
+
const query = { ...crit, _deleted: true };
|
|
585
645
|
try {
|
|
586
646
|
const res = await this.runTimed("updateMany", { collectionName, query }, async () => await collection.updateMany(query, {
|
|
587
647
|
// Restoring is a data change: flip _deleted and bump _updated
|
|
@@ -601,8 +661,9 @@ export class K2DB {
|
|
|
601
661
|
async count(collectionName, criteria) {
|
|
602
662
|
const collection = await this.getCollection(collectionName);
|
|
603
663
|
try {
|
|
664
|
+
const norm = K2DB.normalizeCriteriaIds(criteria || {});
|
|
604
665
|
const query = {
|
|
605
|
-
...
|
|
666
|
+
...norm,
|
|
606
667
|
...(criteria && Object.prototype.hasOwnProperty.call(criteria, "_deleted")
|
|
607
668
|
? {}
|
|
608
669
|
: { _deleted: { $ne: true } }),
|
|
@@ -634,6 +695,8 @@ export class K2DB {
|
|
|
634
695
|
*/
|
|
635
696
|
static sanitiseCriteria(criteria) {
|
|
636
697
|
if (criteria.$match) {
|
|
698
|
+
// Normalize any _uuid values in the match object to uppercase
|
|
699
|
+
criteria.$match = K2DB.normalizeCriteriaIds(criteria.$match);
|
|
637
700
|
for (const key of Object.keys(criteria.$match)) {
|
|
638
701
|
if (typeof criteria.$match[key] !== "string") {
|
|
639
702
|
criteria.$match[key] = K2DB.sanitiseCriteria({
|
|
@@ -649,6 +712,45 @@ export class K2DB {
|
|
|
649
712
|
}
|
|
650
713
|
return criteria;
|
|
651
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
|
+
}
|
|
652
754
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
653
755
|
static stripReservedFields(obj) {
|
|
654
756
|
const out = {};
|
|
@@ -658,6 +760,18 @@ export class K2DB {
|
|
|
658
760
|
}
|
|
659
761
|
return out;
|
|
660
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
|
+
}
|
|
661
775
|
/**
|
|
662
776
|
* Run an async DB operation with timing, slow logging, and hooks.
|
|
663
777
|
*/
|
|
@@ -862,6 +976,7 @@ export class K2DB {
|
|
|
862
976
|
}
|
|
863
977
|
/** Compute the next version number for a document. */
|
|
864
978
|
async nextVersion(collectionName, id) {
|
|
979
|
+
id = K2DB.normalizeId(id);
|
|
865
980
|
const hc = await this.getHistoryCollection(collectionName);
|
|
866
981
|
const last = await hc
|
|
867
982
|
.find({ _uuid: id })
|
|
@@ -888,6 +1003,7 @@ export class K2DB {
|
|
|
888
1003
|
* If maxVersions is provided, prunes oldest snapshots beyond that number.
|
|
889
1004
|
*/
|
|
890
1005
|
async updateVersioned(collectionName, id, data, replace = false, maxVersions) {
|
|
1006
|
+
id = K2DB.normalizeId(id);
|
|
891
1007
|
// Get current doc (excludes deleted) and snapshot it
|
|
892
1008
|
const current = await this.get(collectionName, id);
|
|
893
1009
|
await this.ensureHistoryIndexes(collectionName);
|
|
@@ -916,6 +1032,7 @@ export class K2DB {
|
|
|
916
1032
|
}
|
|
917
1033
|
/** List versions (latest first). */
|
|
918
1034
|
async listVersions(collectionName, id, skip = 0, limit = 20) {
|
|
1035
|
+
id = K2DB.normalizeId(id);
|
|
919
1036
|
const hc = await this.getHistoryCollection(collectionName);
|
|
920
1037
|
const rows = await hc
|
|
921
1038
|
.find({ _uuid: id })
|
|
@@ -928,6 +1045,7 @@ export class K2DB {
|
|
|
928
1045
|
}
|
|
929
1046
|
/** Revert the current document to a specific historical version (preserves metadata). */
|
|
930
1047
|
async revertToVersion(collectionName, id, version) {
|
|
1048
|
+
id = K2DB.normalizeId(id);
|
|
931
1049
|
const hc = await this.getHistoryCollection(collectionName);
|
|
932
1050
|
const row = await hc.findOne({ _uuid: id, _v: version });
|
|
933
1051
|
if (!row) {
|
package/dist/package.json
CHANGED