@actdim/utico 1.1.3 → 1.1.6

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.
Files changed (38) hide show
  1. package/README.md +502 -63
  2. package/dist/arrayExtensions.es.js +29 -29
  3. package/dist/arrayExtensions.es.js.map +1 -1
  4. package/dist/cache/memoryCache.d.ts +2 -3
  5. package/dist/cache/memoryCache.d.ts.map +1 -1
  6. package/dist/cache/memoryCache.es.js +2 -4
  7. package/dist/cache/memoryCache.es.js.map +1 -1
  8. package/dist/cache/persistentCache.d.ts +4 -2
  9. package/dist/cache/persistentCache.d.ts.map +1 -1
  10. package/dist/cache/persistentCache.es.js +25 -26
  11. package/dist/cache/persistentCache.es.js.map +1 -1
  12. package/dist/dataFormats.d.ts +6 -1
  13. package/dist/dataFormats.d.ts.map +1 -1
  14. package/dist/dataFormats.es.js +6 -4
  15. package/dist/dataFormats.es.js.map +1 -1
  16. package/dist/dateTimeDataFormat.d.ts +62 -25
  17. package/dist/dateTimeDataFormat.d.ts.map +1 -1
  18. package/dist/dateTimeDataFormat.es.js +179 -82
  19. package/dist/dateTimeDataFormat.es.js.map +1 -1
  20. package/dist/i18n/cultures.d.ts +36 -8
  21. package/dist/i18n/cultures.d.ts.map +1 -1
  22. package/dist/i18n/cultures.es.js +8 -6
  23. package/dist/i18n/cultures.es.js.map +1 -1
  24. package/dist/i18n/enUsCulture.d.ts +4 -4
  25. package/dist/i18n/enUsCulture.d.ts.map +1 -1
  26. package/dist/i18n/enUsCulture.es.js +17 -18
  27. package/dist/i18n/enUsCulture.es.js.map +1 -1
  28. package/dist/i18n/euCulture.d.ts +30 -0
  29. package/dist/i18n/euCulture.d.ts.map +1 -0
  30. package/dist/i18n/euCulture.es.js +32 -0
  31. package/dist/i18n/euCulture.es.js.map +1 -0
  32. package/dist/i18n/invariantCulture.d.ts +30 -0
  33. package/dist/i18n/invariantCulture.d.ts.map +1 -0
  34. package/dist/i18n/invariantCulture.es.js +32 -0
  35. package/dist/i18n/invariantCulture.es.js.map +1 -0
  36. package/dist/store/storeContracts.d.ts +2 -0
  37. package/dist/store/storeContracts.d.ts.map +1 -1
  38. package/package.json +6 -4
package/README.md CHANGED
@@ -2,20 +2,41 @@
2
2
 
3
3
  A modern foundation toolkit for complex TypeScript apps.
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/@actdim/utico.svg)](https://www.npmjs.com/package/@actdim/utico)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9+-blue.svg)](https://www.typescriptlang.org/)
7
+ [![License: Proprietary](https://img.shields.io/badge/License-Proprietary-red.svg)](LICENSE)
8
+
5
9
  ## Table of Contents
6
10
 
7
11
  - [Installation](#installation)
8
12
  - [Modules](#modules)
9
13
  - [typeCore — Expressive Type Composition](#typecore--expressive-type-composition)
10
14
  - [typeUtils — Runtime Type Utilities](#typeutils--runtime-type-utilities)
15
+ - [stringCore — Locale-Aware String Utilities](#stringcore--locale-aware-string-utilities)
16
+ - [metadata — Property Metadata](#metadata--property-metadata)
17
+ - [decorators — Property Decorators](#decorators--property-decorators)
18
+ - [dateTimeDataFormat — Date/Time Serialisation](#datetimedataformat--datetime-serialisation)
11
19
  - [StructEvent — Typed DOM Events](#structevent--typed-dom-events)
12
20
  - [watchable — Promise & Function Tracking](#watchable--promise--function-tracking)
13
21
  - [asyncMutex — Async Mutual Exclusion](#asyncmutex--async-mutual-exclusion)
14
22
  - [store — Structured Persistence](#store--structured-persistence)
23
+ - [cache — Persistent Cache](#cache--persistent-cache)
15
24
  - [License](#license)
16
25
 
17
26
  ---
18
27
 
28
+ ## Quick Start
29
+
30
+ Try @actdim/utico instantly in your browser without any installation:
31
+
32
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/~/github.com/actdim/utico)
33
+
34
+ Once the project loads, run the tests to see it in action:
35
+
36
+ ```bash
37
+ pnpm run test
38
+ ```
39
+
19
40
  ## Installation
20
41
 
21
42
  ```bash
@@ -27,10 +48,10 @@ pnpm add @actdim/utico
27
48
  **Peer dependencies** (install only what you use):
28
49
 
29
50
  ```bash
30
- pnpm add dexie uuid moment
51
+ pnpm add dexie uuid luxon
31
52
  ```
32
53
 
33
- > `dexie` and `uuid` are required for the `store` module. `moment` is required for date/time formatting utilities.
54
+ > `dexie` and `uuid` are required for the `store` module. `luxon` is required for the `dateTimeDataFormat` module.
34
55
 
35
56
  ---
36
57
 
@@ -196,7 +217,7 @@ generic class without repeating its type arguments everywhere.
196
217
  (see [StructEvent](#structevent--typed-dom-events)).
197
218
  Inside `PersistentCache` you want to work with
198
219
  `StructEvent<PersistentCacheEventStruct, PersistentCache>` as if it were its own named type.
199
- TypeScript offers four ways to achieve this; each has different trade-offs.
220
+ TypeScript offers four ways to achieve this; each has different trade-offs — see the [full comparison](#comparison-4-ways-to-bind-a-generic-constructor) at the end of this section.
200
221
 
201
222
  ---
202
223
 
@@ -248,63 +269,6 @@ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this })
248
269
 
249
270
  ---
250
271
 
251
- #### Comparison: 4 ways to bind a generic constructor
252
-
253
- All four examples produce a bound alias for `StructEvent<PersistentCacheEventStruct, PersistentCache>`.
254
-
255
- **1. Subclass**
256
-
257
- ```ts
258
- class PersistentCacheEvent
259
- extends StructEvent<PersistentCacheEventStruct, PersistentCache> {}
260
- ```
261
-
262
- **2. Manual cast**
263
-
264
- ```ts
265
- type PersistentCacheEvent = StructEvent<PersistentCacheEventStruct, PersistentCache>;
266
- const PersistentCacheEvent = StructEvent as new (
267
- ...args: ConstructorParameters<typeof StructEvent<PersistentCacheEventStruct, PersistentCache>>
268
- ) => PersistentCacheEvent;
269
- ```
270
-
271
- **3. `typed()` + Instantiation Expression** *(recommended)*
272
-
273
- ```ts
274
- const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
275
- const evt = new PersistentCacheEvent("evict", { detail: { records }, target: this });
276
- ```
277
-
278
- **4. `createConstructor()` — callable without `new`**
279
-
280
- ```ts
281
- const PersistentCacheEvent = createConstructor(StructEvent<PersistentCacheEventStruct, PersistentCache>);
282
- const evt = PersistentCacheEvent("evict", { detail: { records }, target: this }); // no new
283
- ```
284
-
285
- ---
286
-
287
- **Feature comparison**
288
-
289
- | | Subclass | Manual cast | `typed()` | `createConstructor()` |
290
- |---|:---:|:---:|:---:|:---:|
291
- | Runtime overhead | new class | none | **none** | wrapper function |
292
- | `new` required | yes | yes | yes | **no** |
293
- | `instanceof` | **yes** | no | no | no |
294
- | Can add methods | **yes** | no | no | no |
295
- | Verbosity | medium | **high** | **low** | **low** |
296
- | Requires TS | any | any | **4.7+** | **4.7+** |
297
-
298
- **When to choose:**
299
-
300
- - **Subclass** — when you need `instanceof` checks, want to add methods, or need a distinct runtime type.
301
- - **Manual cast** — when TS < 4.7 is required, or you prefer zero dependencies (verbose but explicit).
302
- - **`typed()`** — the default choice: concise, zero runtime cost. Requires TS 4.7+.
303
- - **`createConstructor()`** — same as `typed()`, but the constructor must be callable without `new`
304
- (e.g. factory patterns, functional-style code).
305
-
306
- ---
307
-
308
272
  #### Object / Key Utilities
309
273
 
310
274
  | Function | Description |
@@ -313,6 +277,7 @@ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this })
313
277
  | `keyOf<T>(key)` | Returns a property name literal narrowed to `keyof T`. No object required — useful for building typed key references |
314
278
  | `nameOf<T>(f)` | Extracts a property name from a lambda `x => x.prop` at runtime via `Proxy` |
315
279
  | `entry(obj, name, caseInsensitive?)` | Looks up a key (optionally case-insensitive) and returns `[resolvedKey, value]` |
280
+ | `getPrototypes(obj)` | Returns the prototype chain as an array, from the object's direct prototype up to (but not including) `null` |
316
281
 
317
282
  ```ts
318
283
  keysOf({ a: 1, b: 2 }) // => ["a", "b"] typed as ("a" | "b")[]
@@ -370,6 +335,35 @@ combinePropertyPath(["server", "port"]) // => '["server"]["port"]'
370
335
  | `toReadOnly<T>(obj, throwOnSet?)` | Deep read-only proxy; silently ignores writes (or throws if `throwOnSet: true`). Toggle with the `[$lock]` symbol |
371
336
  | `createDeepProxy<T>(target, handler)` | Deep-change proxy: `handler.set` and `handler.deleteProperty` receive the full `DeepPropertyKey` path |
372
337
 
338
+ ```ts
339
+ // proxify — lazy proxy that re-evaluates source on every get/set
340
+ let config = { theme: 'dark' };
341
+ const proxy = proxify(() => config);
342
+ proxy.theme; // => 'dark'
343
+ config = { theme: 'light' };
344
+ proxy.theme; // => 'light' — picks up the new object
345
+
346
+ // toReadOnly — deep read-only proxy (writes silently ignored by default)
347
+ const opts = toReadOnly({ server: { port: 3000 } });
348
+ opts.server.port; // => 3000
349
+ opts.server.port = 80; // silently ignored
350
+ // pass true as second arg to throw on write attempts instead
351
+
352
+ // createDeepProxy — intercept deep mutations with the full property path
353
+ const state = createDeepProxy({ user: { name: 'Alice' } }, {
354
+ set(target, path, value) {
355
+ console.log('set', path.map(String).join('.'), '=', value);
356
+ return true;
357
+ },
358
+ deleteProperty(target, path) {
359
+ console.log('deleted', path.map(String).join('.'));
360
+ return true;
361
+ },
362
+ });
363
+ state.user.name = 'Bob'; // logs: "set user.name = Bob"
364
+ delete state.user.name; // logs: "deleted user.name"
365
+ ```
366
+
373
367
  ---
374
368
 
375
369
  #### JSON Utilities
@@ -405,6 +399,309 @@ getEnumValue(Color, "Purple", Color.Red) // => 0 (default)
405
399
 
406
400
  ---
407
401
 
402
+ #### Comparison: 4 ways to bind a generic constructor
403
+
404
+ All four examples produce a bound alias for `StructEvent<PersistentCacheEventStruct, PersistentCache>`.
405
+
406
+ **1. Subclass**
407
+
408
+ ```ts
409
+ class PersistentCacheEvent
410
+ extends StructEvent<PersistentCacheEventStruct, PersistentCache> {}
411
+ ```
412
+
413
+ **2. Manual cast**
414
+
415
+ ```ts
416
+ type PersistentCacheEvent = StructEvent<PersistentCacheEventStruct, PersistentCache>;
417
+ const PersistentCacheEvent = StructEvent as new (
418
+ ...args: ConstructorParameters<typeof StructEvent<PersistentCacheEventStruct, PersistentCache>>
419
+ ) => PersistentCacheEvent;
420
+ ```
421
+
422
+ **3. `typed()` + Instantiation Expression** *(recommended)*
423
+
424
+ ```ts
425
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
426
+ const evt = new PersistentCacheEvent("evict", { detail: { records }, target: this });
427
+ ```
428
+
429
+ **4. `createConstructor()` — callable without `new`**
430
+
431
+ ```ts
432
+ const PersistentCacheEvent = createConstructor(StructEvent<PersistentCacheEventStruct, PersistentCache>);
433
+ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this }); // no new
434
+ ```
435
+
436
+ ---
437
+
438
+ **Feature comparison**
439
+
440
+ | | Subclass | Manual cast | `typed()` | `createConstructor()` |
441
+ |---|:---:|:---:|:---:|:---:|
442
+ | Runtime overhead | new class | none | **none** | wrapper function |
443
+ | `new` required | yes | yes | yes | **no** |
444
+ | `instanceof` | **yes** | no | no | no |
445
+ | Can add methods | **yes** | no | no | no |
446
+ | Verbosity | medium | **high** | **low** | **low** |
447
+ | Requires TS | any | any | **4.7+** | **4.7+** |
448
+
449
+ **When to choose:**
450
+
451
+ - **Subclass** — when you need `instanceof` checks, want to add methods, or need a distinct runtime type.
452
+ - **Manual cast** — when TS < 4.7 is required, or you prefer zero dependencies (verbose but explicit).
453
+ - **`typed()`** — the default choice: concise, zero runtime cost. Requires TS 4.7+.
454
+ - **`createConstructor()`** — same as `typed()`, but the constructor must be callable without `new`
455
+ (e.g. factory patterns, functional-style code).
456
+
457
+ ---
458
+
459
+ ### stringCore — Locale-Aware String Utilities
460
+
461
+ **Import:** `@actdim/utico/stringCore`
462
+
463
+ Locale-aware string comparison and search utilities built on `Intl.Collator`. All functions accept an optional `locale` parameter (defaults to `navigator.language`). Non-string inputs fall back to reference equality or a default collator instead of throwing.
464
+
465
+ #### Functions
466
+
467
+ | Function | Description |
468
+ |----------|-------------|
469
+ | `equals(strA, strB, ignoreCase?, locale?)` | Returns `true` when strings are equal. Case-sensitive by default. Uses `Intl.Collator` for locale-correct comparison. |
470
+ | `compare(strA, strB, ignoreCase?, locale?)` | Returns a negative, zero, or positive number — same contract as `Array.prototype.sort`. |
471
+ | `ciCompare(strA, strB, locale?)` | Case-insensitive `compare`. Uses `sensitivity: "accent"` when available, falls back to `toLocaleUpperCase`. |
472
+ | `ciStartsWith(str, searchStr, locale?)` | Case-insensitive `String.prototype.startsWith`. Returns `false` for non-string inputs. |
473
+ | `ciEndsWith(str, searchStr, locale?)` | Case-insensitive `String.prototype.endsWith`. Returns `false` for non-string inputs. |
474
+ | `ciIndexOf(str, searchStr, locale?)` | Case-insensitive `String.prototype.indexOf`. Returns `-1` for non-string inputs or no match. |
475
+ | `ciIncludes(str, searchStr, locale?)` | Case-insensitive `String.prototype.includes`. Returns `false` for non-string inputs. |
476
+
477
+ #### Usage Examples
478
+
479
+ ```typescript
480
+ import { equals, compare, ciCompare, ciStartsWith, ciEndsWith, ciIndexOf, ciIncludes } from '@actdim/utico/stringCore';
481
+
482
+ // equals
483
+ equals('Hello', 'hello') // false (case-sensitive)
484
+ equals('Hello', 'hello', true) // true (case-insensitive)
485
+ equals('café', 'CAFÉ', true, 'fr') // true (locale-aware)
486
+
487
+ // compare — for sorting
488
+ ['banana', 'Apple', 'cherry'].sort((a, b) => compare(a, b, true));
489
+ // => ['Apple', 'banana', 'cherry']
490
+
491
+ // ciStartsWith / ciEndsWith
492
+ ciStartsWith('Hello World', 'hello') // true
493
+ ciEndsWith('Hello World', 'WORLD') // true
494
+
495
+ // ciIndexOf / ciIncludes
496
+ ciIndexOf('Hello World', 'WORLD') // 6
497
+ ciIncludes('Hello World', 'WORLD') // true
498
+ ciIncludes('Hello World', 'xyz') // false
499
+ ```
500
+
501
+ ---
502
+
503
+ ### metadata — Property Metadata
504
+
505
+ **Import:** `@actdim/utico/metadata`
506
+
507
+ A lightweight property metadata system backed by `WeakMap`. Attach arbitrary named slots of metadata to class properties via the `@metadata` decorator or the imperative API. Metadata is resolved through the prototype chain, so subclasses automatically inherit base-class metadata.
508
+
509
+ #### Functions
510
+
511
+ | Function | Description |
512
+ |----------|-------------|
513
+ | `metadata(value, slotName)` | Property decorator factory. Attaches `value` to the `slotName` slot of the decorated property. |
514
+ | `getPropertyMetadata<T>(target, propertyName, slotName?)` | Reads metadata for a property. If `slotName` is omitted, returns all slots for that property. Walks the prototype chain. |
515
+ | `updatePropertyMetadata(target, propertyName, value, slotName)` | Imperative equivalent of `@metadata`. |
516
+ | `getPropertyMetadataItem(metadata, obj)` | Low-level: resolves a `WeakMap` entry for `obj` by walking its prototype chain. |
517
+
518
+ #### Usage Examples
519
+
520
+ ```typescript
521
+ import { metadata, getPropertyMetadata, updatePropertyMetadata } from '@actdim/utico/metadata';
522
+
523
+ // --- Decorator API ---
524
+
525
+ class Article {
526
+ @metadata('Title of the article', 'label')
527
+ @metadata(true, 'required')
528
+ title: string;
529
+
530
+ @metadata('Publication date', 'label')
531
+ publishedAt: number;
532
+ }
533
+
534
+ const article = new Article();
535
+
536
+ getPropertyMetadata(article, 'title', 'label') // => 'Title of the article'
537
+ getPropertyMetadata(article, 'title', 'required') // => true
538
+ getPropertyMetadata(article, 'title') // => { label: '...', required: true }
539
+
540
+ // --- Imperative API ---
541
+
542
+ class Product {
543
+ price: number;
544
+ }
545
+
546
+ updatePropertyMetadata(Product.prototype, 'price', 'EUR price in cents', 'label');
547
+ getPropertyMetadata(new Product(), 'price', 'label'); // => 'EUR price in cents'
548
+
549
+ // --- Prototype chain inheritance ---
550
+
551
+ class SpecialArticle extends Article {}
552
+
553
+ // SpecialArticle inherits metadata from Article.prototype
554
+ getPropertyMetadata(new SpecialArticle(), 'title', 'label'); // => 'Title of the article'
555
+ ```
556
+
557
+ ---
558
+
559
+ ### decorators — Property Decorators
560
+
561
+ **Import:** `@actdim/utico/decorators`
562
+
563
+ | Decorator | Description |
564
+ |-----------|-------------|
565
+ | `@nonEnumerable` | Makes a class property non-enumerable: it is hidden from `Object.keys`, `for...in`, `JSON.stringify`, and spread (`{...obj}`), while remaining fully readable and writable via direct access. |
566
+
567
+ #### Usage Examples
568
+
569
+ ```typescript
570
+ import { nonEnumerable } from '@actdim/utico/decorators';
571
+
572
+ class User {
573
+ name: string;
574
+
575
+ @nonEnumerable
576
+ passwordHash: string;
577
+ }
578
+
579
+ const user = new User();
580
+ user.name = 'Alice';
581
+ user.passwordHash = 'abc123';
582
+
583
+ Object.keys(user) // => ['name'] — passwordHash is hidden
584
+ JSON.stringify(user) // => '{"name":"Alice"}'
585
+ user.passwordHash // => 'abc123' — still directly accessible
586
+ ```
587
+
588
+ > **How it works:** the decorator replaces the property with an accessor on the prototype. On the first assignment, the accessor redefines the property as a non-enumerable own value on the specific instance, so subsequent reads are direct (no getter overhead).
589
+
590
+ ---
591
+
592
+ ### dateTimeDataFormat — Date/Time Serialisation
593
+
594
+ **Import:** `@actdim/utico/dateTimeDataFormat`
595
+
596
+ **Peer dependency:** `luxon ^3`
597
+
598
+ Date/time conversion utilities built on [Luxon](https://moment.github.io/luxon/).
599
+ The module converts values from string/number/`Date`/`DateTime`, tracks precision, and exports values either as local ISO (without suffix) or UTC ISO (`Z` suffix).
600
+
601
+ #### Core Types
602
+
603
+ | Type | Description |
604
+ |------|-------------|
605
+ | `DateTimeNumberFormat` | Numeric input/output format: Unix ms, Unix seconds, OADate |
606
+ | `DateTimePrecision` | `auto \| date \| minute \| second \| millisecond` |
607
+ | `DateTimeKind` | `local \| utc` |
608
+ | `DateTimeStringInterpretation` | `auto \| local \| utc` for parsing strings |
609
+ | `DateTimeExportInterpretation` | `original \| local \| utc \| match` for `exportToString` |
610
+ | `DateTimeExtended` | Luxon `DateTime` with extra fields: `precision`, `exportToString(...)` |
611
+ | `ToDateTimeOptions` | Options for `toDateTime(...)` |
612
+ | `DateTimeTransport` | `{ serialize(...), deserialize(...) }` pair for wire transport |
613
+
614
+ #### `DateTimeNumberFormat`
615
+
616
+ | Member | Description |
617
+ |--------|-------------|
618
+ | `UnixTimeMilliseconds` | Default — milliseconds since Unix epoch |
619
+ | `UnixTimeSeconds` | Seconds since Unix epoch |
620
+ | `OADate` | Microsoft OLE Automation date (fractional days since 1899-12-30) |
621
+
622
+ #### Functions
623
+
624
+ | Function | Description |
625
+ |----------|-------------|
626
+ | `toDateTime(source, options?)` | Converts `string \| number \| Date \| DateTime` to `DateTimeExtended` |
627
+ | `getDateTimeFromString(value, format?, precision?, interpretAs?)` | Parses string using explicit format or ISO |
628
+ | `getDateTimeFromNumber(value, numberFormat?, interpretAs?, precision?)` | Parses number into `DateTimeExtended` |
629
+ | `getDateTimeNumber(dt, numberFormat?)` | Converts `DateTime`/`DateTimeExtended` to number |
630
+ | `isDateTimeExtended(obj)` | Type guard for `DateTimeExtended` |
631
+
632
+ #### Ready-to-use transports
633
+
634
+ | Transport | Serialize | Deserialize |
635
+ |-----------|-----------|-------------|
636
+ | `invariantLocalDateTimeTransport` | local ISO without zone suffix | strings as auto, numbers as local Unix seconds |
637
+ | `utcDateTimeTransport` | UTC ISO with `Z` suffix | strings as auto, numbers as UTC Unix seconds |
638
+
639
+ #### Usage Examples
640
+
641
+ ```typescript
642
+ import {
643
+ toDateTime,
644
+ getDateTimeFromString,
645
+ getDateTimeFromNumber,
646
+ getDateTimeNumber,
647
+ DateTimeKind,
648
+ DateTimePrecision,
649
+ DateTimeNumberFormat,
650
+ DateTimeStringInterpretation,
651
+ DateTimeExportInterpretation,
652
+ invariantLocalDateTimeTransport,
653
+ utcDateTimeTransport
654
+ } from '@actdim/utico/dateTimeDataFormat';
655
+
656
+ // Parse from string (ISO auto-detect)
657
+ const a = toDateTime("2024-03-15T10:30:45.123");
658
+
659
+ // Parse from custom string format
660
+ const b = toDateTime("03/15/2024", { stringFormat: "MM/dd/yyyy" });
661
+
662
+ // Parse from number
663
+ const c = getDateTimeFromNumber(1710496245000, DateTimeNumberFormat.UnixTimeMilliseconds);
664
+
665
+ // Parse string and force interpretation as UTC
666
+ const d = getDateTimeFromString(
667
+ "2024-03-15T10:30:45.123",
668
+ undefined,
669
+ DateTimePrecision.Auto,
670
+ DateTimeStringInterpretation.Utc
671
+ );
672
+
673
+ // Export as ISO local (without zone suffix)
674
+ a.exportToString(DateTimeKind.Local); // "2024-03-15T..."
675
+
676
+ // Export as ISO UTC (with Z)
677
+ a.exportToString(DateTimeKind.Utc); // "2024-03-15T...Z"
678
+
679
+ // Export with explicit interpretation
680
+ a.exportToString(
681
+ DateTimeKind.Local,
682
+ DateTimeExportInterpretation.Local
683
+ );
684
+
685
+ // Precision truncation
686
+ const minute = getDateTimeFromString(
687
+ "2024-03-15T10:30:45.123",
688
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
689
+ DateTimePrecision.Minute
690
+ );
691
+ minute.second; // 0
692
+ minute.millisecond; // 0
693
+
694
+ // Number conversion back
695
+ getDateTimeNumber(a, DateTimeNumberFormat.UnixTimeSeconds);
696
+
697
+ // Transport helpers
698
+ invariantLocalDateTimeTransport.serialize(a); // local string
699
+ utcDateTimeTransport.serialize(a); // UTC string with Z
700
+ utcDateTimeTransport.serialize(null); // null
701
+ ```
702
+
703
+ ---
704
+
408
705
  ### StructEvent — Typed DOM Events
409
706
 
410
707
  **Import:** `@actdim/utico/structEvent`
@@ -502,8 +799,8 @@ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, Persi
502
799
  const cache = await PersistentCache.open("my-cache");
503
800
 
504
801
  cache.addEventListener("evict", (e) => {
505
- // e.detail { records: CacheMetadataRecord[] } (typed)
506
- // e.target PersistentCache (typed)
802
+ // e.detail -> { records: CacheMetadataRecord[] } (typed)
803
+ // e.target -> PersistentCache (typed)
507
804
  console.log("Evicted records:", e.detail.records);
508
805
  });
509
806
  ```
@@ -525,9 +822,17 @@ Track the execution state of promises and functions — useful for loading indic
525
822
  | Type | Description |
526
823
  | ------------------------- | ---------------------------------------------------------------- |
527
824
  | `PromiseStatus` | `"pending" \| "fulfilled" \| "rejected"` |
528
- | `WatchablePromise<T>` | `PromiseLike<T>` extended with `status`, `settled`, and `result` |
825
+ | `WatchablePromise<T>` | `PromiseLike<T>` with observable state fields (see below) |
529
826
  | `WatchableFunc<TArgs, T>` | Function extended with an `executing` flag |
530
827
 
828
+ `WatchablePromise<T>` adds three read-only fields to the underlying promise:
829
+
830
+ | Field | Type | Description |
831
+ | ---------- | --------------- | --------------------------------------------------------------------------- |
832
+ | `status` | `PromiseStatus` | `"pending"` immediately; becomes `"fulfilled"` or `"rejected"` when settled |
833
+ | `settled` | `boolean` | Computed getter — `true` once `status` is no longer `"pending"` |
834
+ | `result` | `T \| undefined`| The resolved value after fulfillment; `undefined` after rejection |
835
+
531
836
  #### Functions
532
837
 
533
838
  | Function | Signature | Description |
@@ -714,6 +1019,8 @@ The main entry point for structured persistence. Key features:
714
1019
  | `getOrSet<TValue>(metadata, factory)` | Get existing or create via factory |
715
1020
  | `bulkGet<TValue>(keys)` | Get multiple items |
716
1021
  | `bulkSet<TValue>(metadataRecords, dataRecords)` | Insert multiple items |
1022
+ | `update<TValue>(key, metadataChanges, valueChanges?)` | Patch fields of a single item by key; returns count of records modified (0 if key not found) |
1023
+ | `bulkUpdate(metadataChangeSets, dataChangeSets?)` | Patch fields of multiple items; each change set is `{ key, changes: KeyPathValueMap<T> }` |
717
1024
  | `delete(key)` | Delete an item |
718
1025
  | `bulkDelete(keys)` | Delete multiple items |
719
1026
  | `clear()` | Delete all items |
@@ -788,6 +1095,15 @@ const items = await store.bulkGet<{ x: number }>(['item:1', 'item:2']);
788
1095
  await store.delete('user:42');
789
1096
  await store.bulkDelete(['item:1', 'item:2']);
790
1097
  await store.clear();
1098
+
1099
+ // Patch individual fields without rewriting the whole record
1100
+ await store.update('user:42', { tags: ['admin', 'verified'] });
1101
+
1102
+ // Patch multiple records in one transaction
1103
+ await store.bulkUpdate([
1104
+ { key: 'item:1', changes: { tags: ['sale'] } },
1105
+ { key: 'item:2', changes: { tags: ['new'] } },
1106
+ ]);
791
1107
  ```
792
1108
 
793
1109
  ```typescript
@@ -833,6 +1149,129 @@ const page = await store
833
1149
 
834
1150
  ---
835
1151
 
1152
+ ### cache — Persistent Cache
1153
+
1154
+ **Imports:**
1155
+
1156
+ - `@actdim/utico/cache/persistentCache` — `PersistentCache`, `CacheOptions`, `PersistentCacheOptions`
1157
+ - `@actdim/utico/cache/cacheContracts` — `CacheMetadataRecord`
1158
+
1159
+ Built on top of the `store` module. Adds expiration semantics (TTL, absolute expiration, sliding expiration) and a background cleanup job that evicts expired entries automatically.
1160
+
1161
+ #### Types
1162
+
1163
+ | Type | Description |
1164
+ |------|-------------|
1165
+ | `CacheMetadataRecord` | Extends `MetadataRecord` with `slidingExpiration`, `absoluteExpiration`, and `expiresAt` |
1166
+ | `CacheOptions` | Per-entry expiration options (see below) |
1167
+ | `PersistentCacheOptions` | Cache-level options: `cleanupTimeout` (ms between background cleanup runs) |
1168
+ | `CacheEvictionEvent` | `{ records: CacheMetadataRecord[] }` — payload of the `"evict"` event |
1169
+
1170
+ #### `CacheOptions`
1171
+
1172
+ | Field | Type | Description |
1173
+ |-------|------|-------------|
1174
+ | `slidingExpiration` | `number` (ms) | Extends `expiresAt` by this duration on every `get()`. Recommended: combined with `absoluteExpiration` as a cap. |
1175
+ | `absoluteExpiration` | `Date \| number` | Hard deadline — `expiresAt` is never pushed past this value. |
1176
+ | `ttl` | `number \| { seconds?, minutes?, hours? }` | Sets `absoluteExpiration` relative to the creation time. |
1177
+
1178
+ #### Static Methods
1179
+
1180
+ | Method | Description |
1181
+ |--------|-------------|
1182
+ | `PersistentCache.open(name, options?)` | Open or create a named cache. Returns a `PersistentCache` instance. |
1183
+ | `PersistentCache.exists(name)` | Check if a named cache database exists. |
1184
+ | `PersistentCache.delete(name)` | Delete a named cache database. |
1185
+
1186
+ #### Instance Methods
1187
+
1188
+ | Method | Description |
1189
+ |--------|-------------|
1190
+ | `get<TValue>(key)` | Get an item and update `accessedAt` (and `expiresAt` if `slidingExpiration` is set). |
1191
+ | `set(metadata, value, options)` | Create or overwrite an item with expiration options. Auto-generates a UUID key if `metadata.key` is absent. |
1192
+ | `getOrSet(metadata, factory, options)` | Return the existing item or create it via `factory`. `metadata.key` is required. |
1193
+ | `bulkGet<TValue>(keys)` | Get multiple items by key. |
1194
+ | `bulkSet(metadataRecords, dataRecords, optionsProvider)` | Insert multiple items. `optionsProvider` is called per-record to produce `CacheOptions`. |
1195
+ | `contains(key)` | Check if a key exists. |
1196
+ | `getKeys()` | Return all stored keys. |
1197
+ | `delete(key)` | Delete a single item. |
1198
+ | `bulkDelete(keys)` | Delete multiple items. |
1199
+ | `clear()` | Delete all items. |
1200
+ | `deleteExpired(ts?)` | Evict entries whose `expiresAt < ts` (defaults to `Date.now()`). Fires the `"evict"` event if anything was removed. |
1201
+ | `[Symbol.dispose]()` | Cancel the background cleanup timer and close the database. |
1202
+
1203
+ #### Events
1204
+
1205
+ `PersistentCache` extends `StructEventTarget`. Subscribe with `addEventListener`.
1206
+
1207
+ | Event | Detail type | Description |
1208
+ |-------|-------------|-------------|
1209
+ | `"evict"` | `{ records: CacheMetadataRecord[] }` | Fired after `deleteExpired` removes at least one entry. |
1210
+
1211
+ #### Usage Examples
1212
+
1213
+ ```typescript
1214
+ import { PersistentCache } from '@actdim/utico/cache/persistentCache';
1215
+
1216
+ // --- Open a cache ---
1217
+
1218
+ const cache = await PersistentCache.open('my-cache');
1219
+
1220
+ // --- set / get ---
1221
+
1222
+ await cache.set({ key: 'user:42' }, { name: 'Alice' }, {});
1223
+
1224
+ const item = await cache.get<{ name: string }>('user:42');
1225
+ item.metadata.key // 'user:42'
1226
+ item.metadata.createdAt // timestamp set automatically
1227
+ item.data.value.name // 'Alice'
1228
+
1229
+ // --- Auto-generated key ---
1230
+
1231
+ const meta = {}; // no key provided
1232
+ await cache.set(meta, { x: 1 }, {});
1233
+ meta.key; // UUID filled in by set()
1234
+
1235
+ // --- getOrSet ---
1236
+
1237
+ const item2 = await cache.getOrSet(
1238
+ { key: 'session:abc' },
1239
+ () => ({ token: crypto.randomUUID() }),
1240
+ {}
1241
+ );
1242
+
1243
+ // --- Sliding expiration (renewed on every get) ---
1244
+
1245
+ await cache.set({ key: 'live' }, 'data', { slidingExpiration: 30_000 });
1246
+ // expiresAt = now + 30 s; reset to now + 30 s on each get()
1247
+
1248
+ // --- Sliding expiration with absolute cap ---
1249
+
1250
+ await cache.set({ key: 'bounded' }, 'data', {
1251
+ slidingExpiration: 5 * 60_000, // renew up to 5 min on each get
1252
+ absoluteExpiration: Date.now() + 3_600_000, // but never past 1 hour from now
1253
+ });
1254
+
1255
+ // --- Manual eviction ---
1256
+
1257
+ await cache.deleteExpired(); // uses Date.now()
1258
+ await cache.deleteExpired(Date.now() + 60_000); // treat everything expiring in the next minute as expired
1259
+
1260
+ // --- Eviction event ---
1261
+
1262
+ cache.addEventListener('evict', (e) => {
1263
+ console.log('Evicted:', e.detail.records.map(r => r.key));
1264
+ });
1265
+
1266
+ // --- Cleanup ---
1267
+
1268
+ cache[Symbol.dispose](); // stops background timer, closes DB
1269
+ // or:
1270
+ using c = await PersistentCache.open('temp'); // auto-disposed at block exit (TS 5.2+)
1271
+ ```
1272
+
1273
+ ---
1274
+
836
1275
  ## License
837
1276
 
838
1277
  Proprietary — © Pavel Borodaev