@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 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 type { K2DB, BaseDocument, CreateResult, UpdateResult, DeleteResult, RestoreResult, CountResult, DropResult, VersionedUpdateResult, VersionInfo } from "./db.js";
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 { v4 as uuidv4 } from "uuid";
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: 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
- ...criteria,
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
- const criteria = { ...(filter || {}) };
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 UUID
362
- const newUuid = uuidv4();
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 query = { ...(criteria || {}), _deleted: true };
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
- ...criteria,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frogfish/k2db",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "A data handling library for K2 applications.",
5
5
  "type": "module",
6
6
  "main": "data.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frogfish/k2db",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "A data handling library for K2 applications.",
5
5
  "main": "./dist/data.js",
6
6
  "types": "./dist/data.d.ts",