@actdim/utico 1.1.2 → 1.1.5

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 (69) hide show
  1. package/README.md +1245 -5
  2. package/dist/array.es.js.map +1 -1
  3. package/dist/arrayExtensions.es.js.map +1 -1
  4. package/dist/asyncMutex.d.ts +1 -1
  5. package/dist/asyncMutex.d.ts.map +1 -1
  6. package/dist/asyncMutex.es.js +17 -17
  7. package/dist/asyncMutex.es.js.map +1 -1
  8. package/dist/cache/cacheContracts.es.js.map +1 -1
  9. package/dist/cache/memoryCache.d.ts +2 -3
  10. package/dist/cache/memoryCache.d.ts.map +1 -1
  11. package/dist/cache/memoryCache.es.js +2 -4
  12. package/dist/cache/memoryCache.es.js.map +1 -1
  13. package/dist/cache/persistentCache.d.ts +4 -2
  14. package/dist/cache/persistentCache.d.ts.map +1 -1
  15. package/dist/cache/persistentCache.es.js +41 -40
  16. package/dist/cache/persistentCache.es.js.map +1 -1
  17. package/dist/dataFormats.d.ts +1 -1
  18. package/dist/dataFormats.es.js.map +1 -1
  19. package/dist/dateTimeDataFormat.d.ts +13 -12
  20. package/dist/dateTimeDataFormat.d.ts.map +1 -1
  21. package/dist/dateTimeDataFormat.es.js +62 -68
  22. package/dist/dateTimeDataFormat.es.js.map +1 -1
  23. package/dist/decorators.d.ts +2 -0
  24. package/dist/decorators.d.ts.map +1 -0
  25. package/dist/decorators.es.js +20 -0
  26. package/dist/decorators.es.js.map +1 -0
  27. package/dist/gfx/canvasUtils.es.js.map +1 -1
  28. package/dist/gfx/color.es.js.map +1 -1
  29. package/dist/i18n/cultures.es.js.map +1 -1
  30. package/dist/i18n/enUsCulture.d.ts.map +1 -1
  31. package/dist/i18n/enUsCulture.es.js +17 -18
  32. package/dist/i18n/enUsCulture.es.js.map +1 -1
  33. package/dist/index.es.js.map +1 -1
  34. package/dist/math.es.js.map +1 -1
  35. package/dist/metadata.d.ts +3 -3
  36. package/dist/metadata.d.ts.map +1 -1
  37. package/dist/metadata.es.js +8 -8
  38. package/dist/metadata.es.js.map +1 -1
  39. package/dist/patterns.es.js.map +1 -1
  40. package/dist/store/dataStore.d.ts +17 -43
  41. package/dist/store/dataStore.d.ts.map +1 -1
  42. package/dist/store/dataStore.es.js +98 -92
  43. package/dist/store/dataStore.es.js.map +1 -1
  44. package/dist/store/persistentStore.d.ts +5 -4
  45. package/dist/store/persistentStore.d.ts.map +1 -1
  46. package/dist/store/persistentStore.es.js +12 -11
  47. package/dist/store/persistentStore.es.js.map +1 -1
  48. package/dist/store/storeContracts.d.ts +39 -9
  49. package/dist/store/storeContracts.d.ts.map +1 -1
  50. package/dist/store/storeContracts.es.js +2 -2
  51. package/dist/store/storeContracts.es.js.map +1 -1
  52. package/dist/store/storeDb.es.js +11 -11
  53. package/dist/store/storeDb.es.js.map +1 -1
  54. package/dist/stringCore.d.ts +1 -1
  55. package/dist/stringCore.d.ts.map +1 -1
  56. package/dist/stringCore.es.js +5 -5
  57. package/dist/stringCore.es.js.map +1 -1
  58. package/dist/structEvent.d.ts +4 -3
  59. package/dist/structEvent.d.ts.map +1 -1
  60. package/dist/structEvent.es.js +16 -9
  61. package/dist/structEvent.es.js.map +1 -1
  62. package/dist/typeCore.es.js.map +1 -1
  63. package/dist/typeUtils.d.ts +7 -6
  64. package/dist/typeUtils.d.ts.map +1 -1
  65. package/dist/typeUtils.es.js +38 -34
  66. package/dist/typeUtils.es.js.map +1 -1
  67. package/dist/utils.es.js.map +1 -1
  68. package/dist/watchable.es.js.map +1 -1
  69. package/package.json +7 -4
package/README.md CHANGED
@@ -1,11 +1,1251 @@
1
1
  # @actdim/utico
2
2
 
3
- ## Synchronization
3
+ A modern foundation toolkit for complex TypeScript apps.
4
4
 
5
- ## Metadata reflection
5
+ ## Table of Contents
6
6
 
7
- ## Expressive type composition
7
+ - [Installation](#installation)
8
+ - [Modules](#modules)
9
+ - [typeCore — Expressive Type Composition](#typecore--expressive-type-composition)
10
+ - [typeUtils — Runtime Type Utilities](#typeutils--runtime-type-utilities)
11
+ - [stringCore — Locale-Aware String Utilities](#stringcore--locale-aware-string-utilities)
12
+ - [metadata — Property Metadata](#metadata--property-metadata)
13
+ - [decorators — Property Decorators](#decorators--property-decorators)
14
+ - [dateTimeDataFormat — Date/Time Serialisation](#datetimedataformat--datetime-serialisation)
15
+ - [StructEvent — Typed DOM Events](#structevent--typed-dom-events)
16
+ - [watchable — Promise & Function Tracking](#watchable--promise--function-tracking)
17
+ - [asyncMutex — Async Mutual Exclusion](#asyncmutex--async-mutual-exclusion)
18
+ - [store — Structured Persistence](#store--structured-persistence)
19
+ - [cache — Persistent Cache](#cache--persistent-cache)
20
+ - [License](#license)
8
21
 
9
- ## Structured persistence
22
+ ---
10
23
 
11
- ## Useful type extensions
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @actdim/utico
28
+ # or
29
+ pnpm add @actdim/utico
30
+ ```
31
+
32
+ **Peer dependencies** (install only what you use):
33
+
34
+ ```bash
35
+ pnpm add dexie uuid luxon
36
+ ```
37
+
38
+ > `dexie` and `uuid` are required for the `store` module. `luxon` is required for the `dateTimeDataFormat` module.
39
+
40
+ ---
41
+
42
+ ## Modules
43
+
44
+ ### typeCore — Expressive Type Composition
45
+
46
+ **Import:** `@actdim/utico/typeCore`
47
+
48
+ A comprehensive set of TypeScript utility types and helper functions for advanced type manipulation.
49
+
50
+ #### Types
51
+
52
+ | Type | Description |
53
+ | ---------------------------- | ----------------------------------------------------------------- |
54
+ | `Skip<T, K>` | A more useful version of `Omit` — removes keys `K` from `T` |
55
+ | `Filter<T, V>` | Keeps only properties of `T` whose values extend `V` |
56
+ | `Diff<T, U>` | Properties in `T` that are not in `U` |
57
+ | `StrictDiff<T, U>` | Properties in `T` that differ from `U` (by type) |
58
+ | `CommonPart<T, U>` | Mathematical intersection — shared properties with types from `T` |
59
+ | `CommonKeys<T, U>` | Union of keys shared by `T` and `U` |
60
+ | `UnionToIntersection<U>` | Converts a union type to an intersection type |
61
+ | `ValueUnion<T>` | Union of all property value types in `T` |
62
+ | `KeyPath<T>` | Dot-notation path strings for all nested properties of `T` |
63
+ | `KeyPathValue<T, P>` | Value type at a given `KeyPath` `P` in `T` |
64
+ | `KeyPathValueMap<T>` | Partial map of `KeyPath` strings to their values |
65
+ | `OneOfType<T>` | Discriminated union — exactly one property of `T` is set |
66
+ | `Weaken<T, K>` | Replaces specified keys in `T` with `any` |
67
+ | `Mutable<T>` | Removes `readonly` from all properties |
68
+ | `Overwrite<Base, Overrides>` | Merges types, with `Overrides` taking precedence |
69
+ | `Func<TArgs, T>` | Generic function type |
70
+ | `Action<TArgs>` | Function returning `void` |
71
+ | `AsyncFunc<TArgs, T>` | Async function type |
72
+ | `Executor<T>` | Function returning `T` or `Promise<T>` |
73
+ | `MaybePromise<T>` | `T` or `PromiseLike<T>` |
74
+ | `Factory<T, TArgs>` | Factory function type |
75
+ | `IProvider<TFactory>` | Object with a `get` factory method |
76
+ | `AddPrefix<T, P>` | Adds prefix `P` to a string or to all keys of an object |
77
+ | `AddSuffix<T, S>` | Adds suffix `S` to a string or to all keys of an object |
78
+ | `RemovePrefix<T, P>` | Removes prefix `P` from a string or all keys |
79
+ | `RemoveSuffix<T, S>` | Removes suffix `S` from a string or all keys |
80
+ | `ToUpper<T>` | Uppercases a string literal or all keys of an object |
81
+ | `ToLower<T>` | Lowercases a string literal or all keys of an object |
82
+ | `Constructor` | `new (...args: any[]) => any` |
83
+ | `ConstructorClass<T>` | Extracts the instance type from a constructor |
84
+ | `CallableConstructor<T>` | Constructor callable with or without `new` |
85
+ | `IF<Condition, Then, Else>` | Conditional type alias |
86
+
87
+ #### Functions
88
+
89
+ | Function | Description |
90
+ | ----------------------------- | --------------------------------------------------------------------- |
91
+ | `getPrefixer(prefix)` | Returns `(value: string) => string` that prepends `prefix` |
92
+ | `getValuePrefixer<T>(prefix)` | Returns a function that prefixes all values of a string-valued object |
93
+ | `getKeyPrefixer<T>(prefix)` | Returns a function that prefixes all keys of an object |
94
+
95
+ #### Usage Examples
96
+
97
+ ```typescript
98
+ import type {
99
+ Skip,
100
+ Filter,
101
+ Diff,
102
+ CommonPart,
103
+ KeyPath,
104
+ KeyPathValue,
105
+ KeyPathValueMap,
106
+ OneOfType,
107
+ Mutable,
108
+ Overwrite,
109
+ Func,
110
+ Executor,
111
+ MaybePromise,
112
+ AddPrefix,
113
+ RemovePrefix,
114
+ } from '@actdim/utico/typeCore';
115
+ import { getPrefixer, getValuePrefixer, getKeyPrefixer } from '@actdim/utico/typeCore';
116
+
117
+ // Skip — remove specific keys
118
+ type User = { id: number; name: string; password: string };
119
+ type PublicUser = Skip<User, 'password'>;
120
+ // => { id: number; name: string }
121
+
122
+ // Filter — keep only properties of a given type
123
+ type StringProps = Filter<User, string>;
124
+ // => { name: string; password: string }
125
+
126
+ // Diff — remove overlapping keys
127
+ type A = { x: number; y: number; z: number };
128
+ type B = { y: number };
129
+ type OnlyInA = Diff<A, B>;
130
+ // => { x: number; z: number }
131
+
132
+ // CommonPart — shared properties
133
+ type Common = CommonPart<{ a: number; b: string }, { b: string; c: boolean }>;
134
+ // => { b: string }
135
+
136
+ // KeyPath — deeply nested dot-notation paths
137
+ type Config = { server: { host: string; port: number }; debug: boolean };
138
+ type Paths = KeyPath<Config>;
139
+ // => "server" | "debug" | "server.host" | "server.port"
140
+
141
+ type HostType = KeyPathValue<Config, 'server.host'>;
142
+ // => string
143
+
144
+ // Partial deep-update patch object
145
+ const patch: KeyPathValueMap<Config> = { 'server.port': 8080 };
146
+
147
+ // OneOfType — exactly one property set
148
+ type Payload = OneOfType<{ text: string; html: string; json: object }>;
149
+ // valid: { text: "hello", html: null, json: null }
150
+ // valid: { text: null, html: "<b>hi</b>", json: null }
151
+
152
+ // Mutable — remove readonly
153
+ type ReadonlyPoint = { readonly x: number; readonly y: number };
154
+ type Point = Mutable<ReadonlyPoint>;
155
+ // => { x: number; y: number }
156
+
157
+ // Overwrite — merge with override
158
+ type Base = { id: number; name: string; active: boolean };
159
+ type Updated = Overwrite<Base, { active: string }>;
160
+ // => { id: number; name: string; active: string }
161
+
162
+ // AddPrefix / RemovePrefix on object keys
163
+ type Prefixed = AddPrefix<{ name: string; age: number }, 'user_'>;
164
+ // => { user_name: string; user_age: number }
165
+
166
+ type Unprefixed = RemovePrefix<Prefixed, 'user_'>;
167
+ // => { name: string; age: number }
168
+
169
+ // getPrefixer
170
+ const withNs = getPrefixer('app:');
171
+ withNs('config'); // => "app:config"
172
+
173
+ // getValuePrefixer
174
+ const prefixValues = getValuePrefixer<{ a: string; b: string }>('v_');
175
+ prefixValues({ a: 'foo', b: 'bar' });
176
+ // => { a: 'v_foo', b: 'v_bar' }
177
+
178
+ // getKeyPrefixer
179
+ const prefixKeys = getKeyPrefixer<{ foo: 1; bar: 2 }>('x_');
180
+ prefixKeys({ foo: 1, bar: 2 });
181
+ // => { x_foo: 1, x_bar: 2 }
182
+ ```
183
+
184
+ ---
185
+
186
+ ### typeUtils — Runtime Type Utilities
187
+
188
+ **Import:** `@actdim/utico/typeUtils`
189
+
190
+ Runtime helpers that complement the pure-type utilities in `typeCore`: typed object access,
191
+ property-name reflection, constructor binding, proxies, enums, and JSON helpers.
192
+
193
+ ---
194
+
195
+ #### Constructor Utilities
196
+
197
+ These utilities solve a common TypeScript problem: creating a reusable, pre-typed alias for a
198
+ generic class without repeating its type arguments everywhere.
199
+
200
+ **Background.** Consider `StructEvent<TStruct, TTarget>` — a generic typed event class
201
+ (see [StructEvent](#structevent--typed-dom-events)).
202
+ Inside `PersistentCache` you want to work with
203
+ `StructEvent<PersistentCacheEventStruct, PersistentCache>` as if it were its own named type.
204
+ 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.
205
+
206
+ ---
207
+
208
+ ##### `typed()`
209
+
210
+ ```ts
211
+ function typed<TCtor extends Constructor>(ctor: TCtor): CallableConstructor<TCtor>
212
+ ```
213
+
214
+ Narrows a generic constructor to a pre-typed alias using a TypeScript
215
+ **Instantiation Expression** (TS 4.7+). Zero runtime cost — returns `ctor` as-is.
216
+ The type arguments are bound at the call site by passing `MyClass<A, B>` as a *value expression*
217
+ (without `new`), so the inferred constructor already has the concrete types locked in before
218
+ `typed` is called.
219
+
220
+ ```ts
221
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
222
+
223
+ const evt = new PersistentCacheEvent("evict", {
224
+ detail: { records },
225
+ target: this,
226
+ cancelable: true,
227
+ });
228
+ ```
229
+
230
+ ---
231
+
232
+ ##### `createConstructor()`
233
+
234
+ ```ts
235
+ function createConstructor<TConstructor extends Constructor>(
236
+ type: TConstructor
237
+ ): CallableConstructor<TConstructor>
238
+ ```
239
+
240
+ Same as `typed()` — binds generic type arguments via an Instantiation Expression — but also makes
241
+ the constructor **callable without `new`**. It wraps the class in a plain function that forwards
242
+ all arguments, and patches `prototype` so `instanceof` still works correctly.
243
+
244
+ ```ts
245
+ const PersistentCacheEvent = createConstructor(StructEvent<PersistentCacheEventStruct, PersistentCache>);
246
+
247
+ // no new required:
248
+ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this });
249
+ ```
250
+
251
+ > For primitive-backed types (`String`, `Number`) the original constructor is returned unchanged,
252
+ > since they are already callable without `new`.
253
+
254
+ ---
255
+
256
+ #### Object / Key Utilities
257
+
258
+ | Function | Description |
259
+ |----------|-------------|
260
+ | `keysOf(obj)` | Typed `Object.keys` — returns `(keyof T)[]` instead of `string[]` |
261
+ | `keyOf<T>(key)` | Returns a property name literal narrowed to `keyof T`. No object required — useful for building typed key references |
262
+ | `nameOf<T>(f)` | Extracts a property name from a lambda `x => x.prop` at runtime via `Proxy` |
263
+ | `entry(obj, name, caseInsensitive?)` | Looks up a key (optionally case-insensitive) and returns `[resolvedKey, value]` |
264
+ | `getPrototypes(obj)` | Returns the prototype chain as an array, from the object's direct prototype up to (but not including) `null` |
265
+
266
+ ```ts
267
+ keysOf({ a: 1, b: 2 }) // => ["a", "b"] typed as ("a" | "b")[]
268
+
269
+ keyOf<CacheMetadataRecord>("expiresAt") // => "expiresAt" — typed, no runtime object needed
270
+
271
+ nameOf<User>(x => x.email) // => "email"
272
+ ```
273
+
274
+ ---
275
+
276
+ #### Constraint Helpers
277
+
278
+ | Function | Description |
279
+ |----------|-------------|
280
+ | `satisfies<TShape>()` | Curried constraint: validates `obj` extends `TShape` without widening the inferred type |
281
+ | `strictSatisfies<T>()` | Like `satisfies`, but also rejects objects with extra keys beyond the shape |
282
+
283
+ ```ts
284
+ const opts = satisfies<{ timeout: number }>()({ timeout: 5000, retries: 3 });
285
+ // opts is inferred as { timeout: number; retries: number }, not widened to { timeout: number }
286
+ ```
287
+
288
+ ---
289
+
290
+ #### Assignment Utilities
291
+
292
+ | Function | Description |
293
+ |----------|-------------|
294
+ | `assignWith(dst, src, callback?)` | Conditional assign: iterates `src` keys, calls `callback(key, value, set)` to control each assignment |
295
+ | `update(dst, src, props?)` | Typed assign: `src` must be `Partial<T>`; optional `props` list limits which keys are copied |
296
+ | `copy(src, dst, props?)` | Like `update` but with source and destination swapped |
297
+
298
+ ---
299
+
300
+ #### Path Utilities
301
+
302
+ | Function | Description |
303
+ |----------|-------------|
304
+ | `getPropertyPath<T>(expr)` | Captures a property access chain from a lambda as `(string \| number \| symbol)[]` via recursive proxy |
305
+ | `combinePropertyPath(path)` | Serialises a path array into bracket-notation: `["nested"]["0"]["name"]` |
306
+
307
+ ```ts
308
+ getPropertyPath<Config>(x => x.server.port) // => ["server", "port"]
309
+ combinePropertyPath(["server", "port"]) // => '["server"]["port"]'
310
+ ```
311
+
312
+ ---
313
+
314
+ #### Proxy Utilities
315
+
316
+ | Function | Description |
317
+ |----------|-------------|
318
+ | `proxify<T>(source)` | Lazy proxy: forwards every get/set to `source()` evaluated at access time |
319
+ | `toReadOnly<T>(obj, throwOnSet?)` | Deep read-only proxy; silently ignores writes (or throws if `throwOnSet: true`). Toggle with the `[$lock]` symbol |
320
+ | `createDeepProxy<T>(target, handler)` | Deep-change proxy: `handler.set` and `handler.deleteProperty` receive the full `DeepPropertyKey` path |
321
+
322
+ ```ts
323
+ // proxify — lazy proxy that re-evaluates source on every get/set
324
+ let config = { theme: 'dark' };
325
+ const proxy = proxify(() => config);
326
+ proxy.theme; // => 'dark'
327
+ config = { theme: 'light' };
328
+ proxy.theme; // => 'light' — picks up the new object
329
+
330
+ // toReadOnly — deep read-only proxy (writes silently ignored by default)
331
+ const opts = toReadOnly({ server: { port: 3000 } });
332
+ opts.server.port; // => 3000
333
+ opts.server.port = 80; // silently ignored
334
+ // pass true as second arg to throw on write attempts instead
335
+
336
+ // createDeepProxy — intercept deep mutations with the full property path
337
+ const state = createDeepProxy({ user: { name: 'Alice' } }, {
338
+ set(target, path, value) {
339
+ console.log('set', path.map(String).join('.'), '=', value);
340
+ return true;
341
+ },
342
+ deleteProperty(target, path) {
343
+ console.log('deleted', path.map(String).join('.'));
344
+ return true;
345
+ },
346
+ });
347
+ state.user.name = 'Bob'; // logs: "set user.name = Bob"
348
+ delete state.user.name; // logs: "deleted user.name"
349
+ ```
350
+
351
+ ---
352
+
353
+ #### JSON Utilities
354
+
355
+ | Function | Description |
356
+ |----------|-------------|
357
+ | `orderedStringify(obj, keyCompareFn?, replacer?, space?)` | Stable JSON serialisation: sorts object keys recursively before stringifying |
358
+ | `jsonEquals(obj1, obj2)` | Structural equality via `orderedStringify` |
359
+ | `jsonClone<T>(obj)` | Deep clone via `JSON.parse(JSON.stringify(obj))` — for plain JSON-serialisable data |
360
+
361
+ ```ts
362
+ jsonEquals({ b: 2, a: 1 }, { a: 1, b: 2 }) // => true (key order doesn't matter)
363
+ ```
364
+
365
+ ---
366
+
367
+ #### Enum Utilities
368
+
369
+ | Function | Description |
370
+ |----------|-------------|
371
+ | `getEnumKeys<T>(enumType)` | Returns the string keys of a TS enum, filtering reverse-mapping numeric keys |
372
+ | `getEnumValues<T>(enumType)` | Returns the values of a TS enum |
373
+ | `getEnumValue<T>(enumType, name, defaultValue)` | Looks up an enum member by name; returns `defaultValue` when missing |
374
+
375
+ ```ts
376
+ enum Color { Red = 0, Green = 1, Blue = 2 }
377
+
378
+ getEnumKeys(Color) // => ["Red", "Green", "Blue"]
379
+ getEnumValues(Color) // => [0, 1, 2]
380
+ getEnumValue(Color, "Green", Color.Red) // => 1
381
+ getEnumValue(Color, "Purple", Color.Red) // => 0 (default)
382
+ ```
383
+
384
+ ---
385
+
386
+ #### Comparison: 4 ways to bind a generic constructor
387
+
388
+ All four examples produce a bound alias for `StructEvent<PersistentCacheEventStruct, PersistentCache>`.
389
+
390
+ **1. Subclass**
391
+
392
+ ```ts
393
+ class PersistentCacheEvent
394
+ extends StructEvent<PersistentCacheEventStruct, PersistentCache> {}
395
+ ```
396
+
397
+ **2. Manual cast**
398
+
399
+ ```ts
400
+ type PersistentCacheEvent = StructEvent<PersistentCacheEventStruct, PersistentCache>;
401
+ const PersistentCacheEvent = StructEvent as new (
402
+ ...args: ConstructorParameters<typeof StructEvent<PersistentCacheEventStruct, PersistentCache>>
403
+ ) => PersistentCacheEvent;
404
+ ```
405
+
406
+ **3. `typed()` + Instantiation Expression** *(recommended)*
407
+
408
+ ```ts
409
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
410
+ const evt = new PersistentCacheEvent("evict", { detail: { records }, target: this });
411
+ ```
412
+
413
+ **4. `createConstructor()` — callable without `new`**
414
+
415
+ ```ts
416
+ const PersistentCacheEvent = createConstructor(StructEvent<PersistentCacheEventStruct, PersistentCache>);
417
+ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this }); // no new
418
+ ```
419
+
420
+ ---
421
+
422
+ **Feature comparison**
423
+
424
+ | | Subclass | Manual cast | `typed()` | `createConstructor()` |
425
+ |---|:---:|:---:|:---:|:---:|
426
+ | Runtime overhead | new class | none | **none** | wrapper function |
427
+ | `new` required | yes | yes | yes | **no** |
428
+ | `instanceof` | **yes** | no | no | no |
429
+ | Can add methods | **yes** | no | no | no |
430
+ | Verbosity | medium | **high** | **low** | **low** |
431
+ | Requires TS | any | any | **4.7+** | **4.7+** |
432
+
433
+ **When to choose:**
434
+
435
+ - **Subclass** — when you need `instanceof` checks, want to add methods, or need a distinct runtime type.
436
+ - **Manual cast** — when TS < 4.7 is required, or you prefer zero dependencies (verbose but explicit).
437
+ - **`typed()`** — the default choice: concise, zero runtime cost. Requires TS 4.7+.
438
+ - **`createConstructor()`** — same as `typed()`, but the constructor must be callable without `new`
439
+ (e.g. factory patterns, functional-style code).
440
+
441
+ ---
442
+
443
+ ### stringCore — Locale-Aware String Utilities
444
+
445
+ **Import:** `@actdim/utico/stringCore`
446
+
447
+ 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.
448
+
449
+ #### Functions
450
+
451
+ | Function | Description |
452
+ |----------|-------------|
453
+ | `equals(strA, strB, ignoreCase?, locale?)` | Returns `true` when strings are equal. Case-sensitive by default. Uses `Intl.Collator` for locale-correct comparison. |
454
+ | `compare(strA, strB, ignoreCase?, locale?)` | Returns a negative, zero, or positive number — same contract as `Array.prototype.sort`. |
455
+ | `ciCompare(strA, strB, locale?)` | Case-insensitive `compare`. Uses `sensitivity: "accent"` when available, falls back to `toLocaleUpperCase`. |
456
+ | `ciStartsWith(str, searchStr, locale?)` | Case-insensitive `String.prototype.startsWith`. Returns `false` for non-string inputs. |
457
+ | `ciEndsWith(str, searchStr, locale?)` | Case-insensitive `String.prototype.endsWith`. Returns `false` for non-string inputs. |
458
+ | `ciIndexOf(str, searchStr, locale?)` | Case-insensitive `String.prototype.indexOf`. Returns `-1` for non-string inputs or no match. |
459
+ | `ciIncludes(str, searchStr, locale?)` | Case-insensitive `String.prototype.includes`. Returns `false` for non-string inputs. |
460
+
461
+ #### Usage Examples
462
+
463
+ ```typescript
464
+ import { equals, compare, ciCompare, ciStartsWith, ciEndsWith, ciIndexOf, ciIncludes } from '@actdim/utico/stringCore';
465
+
466
+ // equals
467
+ equals('Hello', 'hello') // false (case-sensitive)
468
+ equals('Hello', 'hello', true) // true (case-insensitive)
469
+ equals('café', 'CAFÉ', true, 'fr') // true (locale-aware)
470
+
471
+ // compare — for sorting
472
+ ['banana', 'Apple', 'cherry'].sort((a, b) => compare(a, b, true));
473
+ // => ['Apple', 'banana', 'cherry']
474
+
475
+ // ciStartsWith / ciEndsWith
476
+ ciStartsWith('Hello World', 'hello') // true
477
+ ciEndsWith('Hello World', 'WORLD') // true
478
+
479
+ // ciIndexOf / ciIncludes
480
+ ciIndexOf('Hello World', 'WORLD') // 6
481
+ ciIncludes('Hello World', 'WORLD') // true
482
+ ciIncludes('Hello World', 'xyz') // false
483
+ ```
484
+
485
+ ---
486
+
487
+ ### metadata — Property Metadata
488
+
489
+ **Import:** `@actdim/utico/metadata`
490
+
491
+ 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.
492
+
493
+ #### Functions
494
+
495
+ | Function | Description |
496
+ |----------|-------------|
497
+ | `metadata(value, slotName)` | Property decorator factory. Attaches `value` to the `slotName` slot of the decorated property. |
498
+ | `getPropertyMetadata<T>(target, propertyName, slotName?)` | Reads metadata for a property. If `slotName` is omitted, returns all slots for that property. Walks the prototype chain. |
499
+ | `updatePropertyMetadata(target, propertyName, value, slotName)` | Imperative equivalent of `@metadata`. |
500
+ | `getPropertyMetadataItem(metadata, obj)` | Low-level: resolves a `WeakMap` entry for `obj` by walking its prototype chain. |
501
+
502
+ #### Usage Examples
503
+
504
+ ```typescript
505
+ import { metadata, getPropertyMetadata, updatePropertyMetadata } from '@actdim/utico/metadata';
506
+
507
+ // --- Decorator API ---
508
+
509
+ class Article {
510
+ @metadata('Title of the article', 'label')
511
+ @metadata(true, 'required')
512
+ title: string;
513
+
514
+ @metadata('Publication date', 'label')
515
+ publishedAt: number;
516
+ }
517
+
518
+ const article = new Article();
519
+
520
+ getPropertyMetadata(article, 'title', 'label') // => 'Title of the article'
521
+ getPropertyMetadata(article, 'title', 'required') // => true
522
+ getPropertyMetadata(article, 'title') // => { label: '...', required: true }
523
+
524
+ // --- Imperative API ---
525
+
526
+ class Product {
527
+ price: number;
528
+ }
529
+
530
+ updatePropertyMetadata(Product.prototype, 'price', 'EUR price in cents', 'label');
531
+ getPropertyMetadata(new Product(), 'price', 'label'); // => 'EUR price in cents'
532
+
533
+ // --- Prototype chain inheritance ---
534
+
535
+ class SpecialArticle extends Article {}
536
+
537
+ // SpecialArticle inherits metadata from Article.prototype
538
+ getPropertyMetadata(new SpecialArticle(), 'title', 'label'); // => 'Title of the article'
539
+ ```
540
+
541
+ ---
542
+
543
+ ### decorators — Property Decorators
544
+
545
+ **Import:** `@actdim/utico/decorators`
546
+
547
+ | Decorator | Description |
548
+ |-----------|-------------|
549
+ | `@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. |
550
+
551
+ #### Usage Examples
552
+
553
+ ```typescript
554
+ import { nonEnumerable } from '@actdim/utico/decorators';
555
+
556
+ class User {
557
+ name: string;
558
+
559
+ @nonEnumerable
560
+ passwordHash: string;
561
+ }
562
+
563
+ const user = new User();
564
+ user.name = 'Alice';
565
+ user.passwordHash = 'abc123';
566
+
567
+ Object.keys(user) // => ['name'] — passwordHash is hidden
568
+ JSON.stringify(user) // => '{"name":"Alice"}'
569
+ user.passwordHash // => 'abc123' — still directly accessible
570
+ ```
571
+
572
+ > **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).
573
+
574
+ ---
575
+
576
+ ### dateTimeDataFormat — Date/Time Serialisation
577
+
578
+ **Import:** `@actdim/utico/dateTimeDataFormat`
579
+
580
+ **Peer dependency:** `luxon ^3`
581
+
582
+ UTC-first date/time utilities built on [Luxon](https://moment.github.io/luxon/). Provides a canonical wire format (`yyyy-MM-dd'T'HH:mm:ss.SSS`), serialisation/deserialisation, display formatting, and helpers for OLE Automation and numeric timestamps.
583
+
584
+ #### Types
585
+
586
+ | Type | Description |
587
+ |------|-------------|
588
+ | `DateTimeDataFormat` | Interface for the default export: `serialize`, `deserialize`, `tryDeserialize`, `normalize`, `isValid`, `serializationFormat` |
589
+ | `DateValueFormats` | `{ string?: string; number?: DateNumberFormat }` — format hints passed to `toDateTime` |
590
+
591
+ #### Enum: `DateNumberFormat`
592
+
593
+ | Member | Description |
594
+ |--------|-------------|
595
+ | `UnixTimeMilliseconds` | Default — milliseconds since Unix epoch |
596
+ | `UnixTimeSeconds` | Seconds since Unix epoch |
597
+ | `OADate` | Microsoft OLE Automation date (fractional days since 1899-12-30) |
598
+
599
+ #### Functions
600
+
601
+ | Function | Description |
602
+ |----------|-------------|
603
+ | `toDateTime(source, formats?)` | Converts `string \| number \| Date \| DateTime` -> `DateTime` (UTC). Accepts optional `DateValueFormats` hints |
604
+ | `fromLocalDate(date)` | Reads the local time parts of a `Date` (e.g. from `normalize()`) and places them in UTC. Use to serialize a normalized `Date` back to the wire format |
605
+ | `formatDate(date, format?)` | Formats a `Date` or `DateTime` using a Luxon format string. Auto-selects a locale format when `format` is omitted |
606
+ | `getDateFromNumber(value, fmt?)` | Converts a numeric timestamp to a `Date` according to `DateNumberFormat` |
607
+ | `getDateFromOADate(oaDate)` | Converts a Microsoft OADate number to `Date` |
608
+ | `getOADateFromDate(date)` | Converts a `Date` to a Microsoft OADate string |
609
+
610
+ #### Default export — `dateTimeFormat`
611
+
612
+ | Method / Property | Signature | Description |
613
+ |-------------------|-----------|-------------|
614
+ | `serializationFormat` | `string` | `"yyyy-MM-dd'T'HH:mm:ss.SSS"` — the canonical wire format |
615
+ | `serialize(source)` | `-> string \| null` | Formats any supported source to the wire format |
616
+ | `deserialize(value)` | `-> DateTime` | Parses a wire-format string; throws on invalid input |
617
+ | `tryDeserialize(value)` | `-> DateTime \| null` | Like `deserialize` but returns `null` instead of throwing |
618
+ | `isValid(source)` | `-> boolean` | `true` for `null`, a valid wire-format string, or any `Date` |
619
+ | `normalize(source)` | `-> Date \| null` | Converts any supported source to a native `Date`. The local accessors (`getHours()`, etc.) reflect the original UTC values |
620
+
621
+ #### Usage Examples
622
+
623
+ ```typescript
624
+ import dateTimeFormat, { toDateTime, fromLocalDate, formatDate, DateNumberFormat } from '@actdim/utico/dateTimeDataFormat';
625
+ import { DateTime } from 'luxon';
626
+
627
+ // Deserialize a wire-format string -> Luxon DateTime (UTC)
628
+ const dt = dateTimeFormat.deserialize("2024-03-15T10:30:45.123");
629
+ dt.year; // 2024
630
+ dt.hour; // 10
631
+ dt.zoneName; // "UTC"
632
+
633
+ // Serialize back to wire format
634
+ dateTimeFormat.serialize(dt); // "2024-03-15T10:30:45.123"
635
+ dateTimeFormat.serialize(new Date(...)); // same format
636
+
637
+ // Safe parse — returns null instead of throwing
638
+ dateTimeFormat.tryDeserialize("bad"); // null
639
+
640
+ // isValid
641
+ dateTimeFormat.isValid("2024-03-15T10:30:45.000"); // true
642
+ dateTimeFormat.isValid("not-a-date"); // false
643
+ dateTimeFormat.isValid(null); // true (no value is OK)
644
+
645
+ // normalize — native Date whose getHours() reflects the UTC hour
646
+ const date = dateTimeFormat.normalize("2024-03-15T10:30:45.000");
647
+ date.getHours(); // 10 — regardless of local timezone
648
+
649
+ // toDateTime — flexible conversion
650
+ toDateTime("2024-03-15T10:30:45.000"); // from s11n string
651
+ toDateTime(new Date()); // from Date
652
+ toDateTime(1710496245000, { number: DateNumberFormat.UnixTimeMilliseconds });
653
+ toDateTime(1710496245, { number: DateNumberFormat.UnixTimeSeconds });
654
+ toDateTime("03/15/2024", { string: "MM/dd/yyyy" }); // custom format
655
+
656
+ // formatDate — display formatting
657
+ formatDate(dt, "yyyy-MM-dd"); // "2024-03-15"
658
+ formatDate(dt); // auto-selected locale format
659
+
660
+ // fromLocalDate — serialize a normalize()'d Date back to the wire format
661
+ //
662
+ // normalize() produces a Date whose getHours() equals the original UTC hour.
663
+ // toDateTime(date) reads the epoch as UTC, which gives the wrong result for
664
+ // such Dates. fromLocalDate() reads the local time parts instead.
665
+ //
666
+ const normalized = dateTimeFormat.normalize("2024-03-15T10:30:45.123");
667
+ normalized.getHours(); // 10 — correct for display in <input>
668
+ dateTimeFormat.serialize(fromLocalDate(normalized)); // "2024-03-15T10:30:45.123" ✓
669
+
670
+ // Typical <input type="datetime-local"> round-trip:
671
+ // input.valueAsDate = dateTimeFormat.normalize(serverValue)
672
+ // ...user edits...
673
+ // const saved = dateTimeFormat.serialize(input.value); // simplest
674
+ // const saved = dateTimeFormat.serialize(fromLocalDate(input.valueAsDate)); // via Date
675
+ ```
676
+
677
+ ---
678
+
679
+ ### StructEvent — Typed DOM Events
680
+
681
+ **Import:** `@actdim/utico/structEvent`
682
+
683
+ `StructEvent` and `StructEventTarget` bring the standard DOM `EventTarget` / `CustomEvent` API
684
+ into TypeScript's type system. You describe every event your class can emit as a **struct** — a
685
+ plain object type where keys are event names and values are the `detail` payload types — and the
686
+ compiler enforces correct event names, `detail` shapes, and listener signatures everywhere.
687
+
688
+ #### Classes
689
+
690
+ | Class | Description |
691
+ |-------|-------------|
692
+ | `StructEvent<TStruct, TTarget, TType>` | Typed `CustomEvent`. `.detail` is `TStruct[TType]`; `.target` is `TTarget`. `TType` defaults to all keys of `TStruct` and is inferred automatically when constructing. |
693
+ | `StructEventTarget<TStruct>` | `EventTarget` subclass with typed overloads for `addEventListener`, `removeEventListener`, `dispatchEvent`, and `hasEventListener`. |
694
+
695
+ #### Constructor: `StructEvent`
696
+
697
+ ```ts
698
+ new StructEvent<TStruct, TTarget, TType>(
699
+ type: TType,
700
+ eventInitDict?: CustomEventInit<TStruct[TType]> & { target: TTarget }
701
+ )
702
+ ```
703
+
704
+ #### Methods: `StructEventTarget`
705
+
706
+ | Method | Description |
707
+ |--------|-------------|
708
+ | `addEventListener<K>(type, listener, options?)` | Adds a typed listener; `listener` receives `StructEvent<TStruct, this, K>` |
709
+ | `removeEventListener<K>(type, listener, options?)` | Removes a previously added typed listener |
710
+ | `dispatchEvent<K>(event)` | Dispatches a `StructEvent`; TypeScript rejects events whose struct does not match |
711
+ | `hasEventListener<K>(type, listener)` | Returns `true` if the exact listener is currently registered |
712
+
713
+ #### Usage Examples
714
+
715
+ ```typescript
716
+ import { StructEvent, StructEventTarget } from '@actdim/utico/structEvent';
717
+ import { typed } from '@actdim/utico/typeUtils';
718
+
719
+ // --- 1. Define the event struct ---
720
+ // Keys = event names, values = detail payload types
721
+
722
+ type PersistentCacheEventStruct = {
723
+ evict: { records: CacheMetadataRecord[] };
724
+ };
725
+
726
+ // --- 2. Extend StructEventTarget ---
727
+
728
+ class PersistentCache extends StructEventTarget<PersistentCacheEventStruct> {
729
+
730
+ // --- Dispatching: Option A — inline `this` type (zero boilerplate) ---
731
+ //
732
+ // Inside a class method `this` is a polymorphic type, so you can pass it
733
+ // directly as the second type argument. TypeScript infers "evict",
734
+ // checks `detail` against the struct, and verifies `target`.
735
+
736
+ async deleteExpiredA() {
737
+ const records = await this.fetchExpired();
738
+
739
+ this.dispatchEvent(
740
+ new StructEvent<PersistentCacheEventStruct, this>("evict", {
741
+ detail: { records },
742
+ target: this,
743
+ cancelable: true,
744
+ })
745
+ );
746
+ }
747
+
748
+ // --- Dispatching: Option B — pre-bound alias with typed() (recommended for reuse) ---
749
+ //
750
+ // Bind the constructor once at module scope (or as a static field).
751
+ // See the Constructor Utilities section in typeUtils for all four
752
+ // binding strategies (subclass, manual cast, typed(), createConstructor()).
753
+
754
+ async deleteExpiredB() {
755
+ const records = await this.fetchExpired();
756
+
757
+ this.dispatchEvent(
758
+ new PersistentCacheEvent("evict", {
759
+ detail: { records },
760
+ target: this,
761
+ cancelable: true,
762
+ })
763
+ );
764
+ }
765
+ }
766
+
767
+ // Alias created once at module scope — equivalent to a named type for
768
+ // StructEvent<PersistentCacheEventStruct, PersistentCache>
769
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
770
+
771
+ // --- 3. Listening to typed events ---
772
+
773
+ const cache = await PersistentCache.open("my-cache");
774
+
775
+ cache.addEventListener("evict", (e) => {
776
+ // e.detail -> { records: CacheMetadataRecord[] } (typed)
777
+ // e.target -> PersistentCache (typed)
778
+ console.log("Evicted records:", e.detail.records);
779
+ });
780
+ ```
781
+
782
+ **All four ways to create the bound alias** are shown in the
783
+ [Constructor Utilities](#constructor-utilities) section of typeUtils, using exactly
784
+ `StructEvent<PersistentCacheEventStruct, PersistentCache>` as the running example.
785
+
786
+ ---
787
+
788
+ ### watchable — Promise & Function Tracking
789
+
790
+ **Import:** `@actdim/utico/watchable`
791
+
792
+ Track the execution state of promises and functions — useful for loading indicators, UI state, and conditional logic without `try/catch` boilerplate.
793
+
794
+ #### Types
795
+
796
+ | Type | Description |
797
+ | ------------------------- | ---------------------------------------------------------------- |
798
+ | `PromiseStatus` | `"pending" \| "fulfilled" \| "rejected"` |
799
+ | `WatchablePromise<T>` | `PromiseLike<T>` with observable state fields (see below) |
800
+ | `WatchableFunc<TArgs, T>` | Function extended with an `executing` flag |
801
+
802
+ `WatchablePromise<T>` adds three read-only fields to the underlying promise:
803
+
804
+ | Field | Type | Description |
805
+ | ---------- | --------------- | --------------------------------------------------------------------------- |
806
+ | `status` | `PromiseStatus` | `"pending"` immediately; becomes `"fulfilled"` or `"rejected"` when settled |
807
+ | `settled` | `boolean` | Computed getter — `true` once `status` is no longer `"pending"` |
808
+ | `result` | `T \| undefined`| The resolved value after fulfillment; `undefined` after rejection |
809
+
810
+ #### Functions
811
+
812
+ | Function | Signature | Description |
813
+ | ------------- | ------------------------------------------------- | --------------------------------------------- |
814
+ | `watch` | `(fn: Executor<T>) => WatchablePromise<T>` | Wraps an executor in a trackable promise |
815
+ | `toWatchable` | `(fn: Func<TArgs, T>) => WatchableFunc<TArgs, T>` | Wraps a function to track its execution state |
816
+
817
+ #### Usage Examples
818
+
819
+ ```typescript
820
+ import { watch, toWatchable } from '@actdim/utico/watchable';
821
+
822
+ // --- watch: observable promise ---
823
+
824
+ const request = watch(async () => {
825
+ const res = await fetch('/api/data');
826
+ return res.json();
827
+ });
828
+
829
+ console.log(request.status); // "pending"
830
+ console.log(request.settled); // false
831
+
832
+ await request;
833
+
834
+ console.log(request.status); // "fulfilled" or "rejected"
835
+ console.log(request.settled); // true
836
+ console.log(request.result); // the resolved value (or undefined if rejected)
837
+
838
+ // Useful in UI: show spinner while pending
839
+ setInterval(() => {
840
+ if (!request.settled) showSpinner();
841
+ else hideSpinner();
842
+ }, 100);
843
+
844
+ // --- toWatchable: track function execution ---
845
+
846
+ const saveData = toWatchable(async (data: object) => {
847
+ await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
848
+ });
849
+
850
+ console.log(saveData.executing); // false
851
+
852
+ saveData({ name: 'Alice' }); // call does not need to be awaited to check state
853
+
854
+ console.log(saveData.executing); // true (async function is running)
855
+
856
+ // Prevent double-submission
857
+ const submitButton = document.querySelector('button')!;
858
+ submitButton.addEventListener('click', () => {
859
+ if (saveData.executing) return; // guard against concurrent calls
860
+ saveData({ name: 'Bob' });
861
+ });
862
+ ```
863
+
864
+ ---
865
+
866
+ ### asyncMutex — Async Mutual Exclusion
867
+
868
+ **Import:** `@actdim/utico/asyncMutex`
869
+
870
+ A lightweight async mutex that serializes concurrent async operations. Prevents race conditions when accessing shared resources.
871
+
872
+ #### Class: `AsyncMutex`
873
+
874
+ | Method | Signature | Description |
875
+ | ---------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
876
+ | `lock` | `(timeoutMs?: number) => Promise<() => void>` | Acquires the lock; returns an `unlock` function. Throws on timeout. |
877
+ | `tryLock` | `() => (() => void) \| null` | Non-blocking acquire; returns `unlock` or `null` if already locked. |
878
+ | `dispatch` | `(fn: Executor<T>, timeoutMs?: number) => Promise<T>` | Acquires lock, runs `fn`, releases lock — the recommended pattern. |
879
+
880
+ #### Usage Examples
881
+
882
+ ```typescript
883
+ import { AsyncMutex } from '@actdim/utico/asyncMutex';
884
+
885
+ const mutex = new AsyncMutex();
886
+
887
+ // --- dispatch: the simplest pattern ---
888
+
889
+ async function updateCounter() {
890
+ return mutex.dispatch(async () => {
891
+ const current = await db.get('counter');
892
+ await db.set('counter', current + 1);
893
+ return current + 1;
894
+ });
895
+ }
896
+
897
+ // Safe to call concurrently — operations are serialized
898
+ await Promise.all([updateCounter(), updateCounter(), updateCounter()]);
899
+
900
+ // --- lock / unlock: manual control ---
901
+
902
+ async function criticalSection() {
903
+ const unlock = await mutex.lock();
904
+ try {
905
+ await doWork();
906
+ } finally {
907
+ unlock(); // always release!
908
+ }
909
+ }
910
+
911
+ // --- lock with timeout ---
912
+
913
+ async function timedSection() {
914
+ let unlock: (() => void) | undefined;
915
+ try {
916
+ unlock = await mutex.lock(5000); // wait at most 5 seconds
917
+ await doSlowWork();
918
+ } catch (e) {
919
+ if ((e as Error).message === 'Mutex lock timeout') {
920
+ console.warn('Could not acquire lock in time');
921
+ } else {
922
+ throw e;
923
+ }
924
+ } finally {
925
+ unlock?.();
926
+ }
927
+ }
928
+
929
+ // --- tryLock: fire-and-forget, skip if busy ---
930
+
931
+ function syncSnapshot() {
932
+ const unlock = mutex.tryLock();
933
+ if (!unlock) {
934
+ console.log('Already syncing, skipping...');
935
+ return;
936
+ }
937
+ try {
938
+ takeSnapshot();
939
+ } finally {
940
+ unlock();
941
+ }
942
+ }
943
+ ```
944
+
945
+ ---
946
+
947
+ ### store — Structured Persistence
948
+
949
+ **Imports:**
950
+
951
+ - `@actdim/utico/store/storeContracts` — types and interfaces
952
+ - `@actdim/utico/store/persistentStore` — `PersistentStore` (main entry point)
953
+
954
+ Built on [Dexie](https://dexie.org/) (IndexedDB). Uses `AsyncMutex` internally to protect concurrent database access. Transactions are managed automatically — no manual transaction handling needed.
955
+
956
+ #### Core Types
957
+
958
+ | Type / Class | Description |
959
+ |--------------|-------------|
960
+ | `MetadataRecord` | Base metadata: `key`, `createdAt`, `updatedAt`, `tags` |
961
+ | `DataRecord<TValue>` | `{ key: string; value: TValue }` |
962
+ | `StoreItem<T, TValue>` | Combined: `{ metadata?: T; data?: DataRecord<TValue> }` |
963
+ | `ChangeSet<T>` | `{ key: string; changes: KeyPathValueMap<T> }` |
964
+ | `FieldDef<T>` | Index definition for a field of `T`: `"field"`, `"&field"` (unique), `"*field"` (multi-entry), `"++field"` (auto-increment) |
965
+ | `FieldDefTemplate<T>` | `FieldDef<T>[]` — full index schema; TypeScript enforces valid field names and modifier combinations |
966
+ | `OrderDirection` | `"asc" \| "desc"` |
967
+
968
+ #### Class: `PersistentStore<T extends MetadataRecord>`
969
+
970
+ The main entry point for structured persistence. Key features:
971
+
972
+ - **Standard metadata out of the box** — `key`, `createdAt` (auto), `updatedAt` (auto), `tags`
973
+ - **Custom metadata types** — extend `MetadataRecord` with your own fields and pass a generic type parameter
974
+ - **Custom indexed fields** — declare additional indexes via `FieldDefTemplate` to enable fast index-based queries
975
+ - **Type-safe querying** — `where()` accepts only declared indexed fields; value types match the field type
976
+ - **No transaction boilerplate** — all operations run in optimal transactions automatically
977
+
978
+ | Static Method | Description |
979
+ |---------------|-------------|
980
+ | `PersistentStore.open<T>(name, fieldDefTemplate?, options?)` | Open or create a named store. Pass a custom `fieldDefTemplate` when using a custom metadata type. |
981
+ | `PersistentStore.exists(name)` | Check if a named store exists |
982
+ | `PersistentStore.delete(name)` | Delete a named store |
983
+
984
+ #### Interface: `IPersistentStore<T>`
985
+
986
+ | Method | Description |
987
+ |--------|-------------|
988
+ | `open()` | Open the database (called automatically by `PersistentStore.open`) |
989
+ | `getKeys()` | Get all stored keys |
990
+ | `contains(key)` | Check if a key exists |
991
+ | `get<TValue>(key)` | Get a single item by key |
992
+ | `set<TValue>(metadata, value)` | Create or overwrite an item |
993
+ | `getOrSet<TValue>(metadata, factory)` | Get existing or create via factory |
994
+ | `bulkGet<TValue>(keys)` | Get multiple items |
995
+ | `bulkSet<TValue>(metadataRecords, dataRecords)` | Insert multiple items |
996
+ | `update<TValue>(key, metadataChanges, valueChanges?)` | Patch fields of a single item by key; returns count of records modified (0 if key not found) |
997
+ | `bulkUpdate(metadataChangeSets, dataChangeSets?)` | Patch fields of multiple items; each change set is `{ key, changes: KeyPathValueMap<T> }` |
998
+ | `delete(key)` | Delete an item |
999
+ | `bulkDelete(keys)` | Delete multiple items |
1000
+ | `clear()` | Delete all items |
1001
+ | `query<TValue>()` | In-memory filterable/pageable collection over all items |
1002
+ | `where<K>(field)` | Start an index-based query on a declared indexed field |
1003
+ | `orderBy(field, direction)` | Get items ordered by an indexed field |
1004
+ | `distinct(field)` | Get items with unique values of an indexed field |
1005
+
1006
+ #### Index Schema
1007
+
1008
+ The field template is an array of `FieldDef` strings. The first entry is always the primary key.
1009
+
1010
+ | Syntax | Meaning |
1011
+ |--------|---------|
1012
+ | `"field"` | Regular index |
1013
+ | `"&field"` | Unique index |
1014
+ | `"*field"` | Multi-entry index (for array-valued fields, e.g. `tags`) |
1015
+ | `"++field"` | Auto-increment index |
1016
+
1017
+ `defaultMetadataFieldDefTemplate` (exported from `persistentStore`) provides the base schema `["&key", "createdAt", "updatedAt", "tags"]`. Spread it when adding custom fields:
1018
+
1019
+ ```typescript
1020
+ [...defaultMetadataFieldDefTemplate, "score", "*categories"]
1021
+ ```
1022
+
1023
+ TypeScript enforces that every entry is a valid `FieldDef<keyof T>` — only field names from your metadata type (with their modifier variants) are suggested and accepted. Fields not in the template are still stored, but cannot be used in `where()` or `orderBy()`.
1024
+
1025
+ #### `where()` — Index-based Queries
1026
+
1027
+ `where(field)` returns a `WhereFilter` typed to the field's value type. All methods return an `IStoreCollection` chainable with `.filter()`, `.limit()`, `.offset()`, `.toArray()`, `.getCount()`, etc.
1028
+
1029
+ | Method | Description |
1030
+ |--------|-------------|
1031
+ | `equals(value)` | Exact match |
1032
+ | `above(value)` | Strictly greater than (numbers, strings, dates) |
1033
+ | `aboveOrEqual(value)` | Greater than or equal |
1034
+ | `below(value)` | Strictly less than |
1035
+ | `belowOrEqual(value)` | Less than or equal |
1036
+ | `between(lower, upper)` | Range (inclusive/exclusive) |
1037
+ | `anyOf(values[])` | Matches any value in the list |
1038
+ | `noneOf(values[])` | Excludes values in the list |
1039
+ | `startsWith(prefix)` | String prefix match |
1040
+ | `equalsIgnoreCase(value)` | Case-insensitive string match |
1041
+
1042
+ #### Usage Examples
1043
+
1044
+ ```typescript
1045
+ import { PersistentStore } from '@actdim/utico/store/persistentStore';
1046
+
1047
+ // --- Basic usage ---
1048
+
1049
+ const store = await PersistentStore.open('my-app-store');
1050
+
1051
+ await store.set({ key: 'user:42' }, { name: 'Alice', role: 'admin' });
1052
+
1053
+ const item = await store.get<{ name: string; role: string }>('user:42');
1054
+ console.log(item.metadata?.createdAt); // auto-set on write
1055
+ console.log(item.data?.value.name); // "Alice"
1056
+
1057
+ await store.getOrSet(
1058
+ { key: 'session:abc' },
1059
+ () => ({ token: crypto.randomUUID(), expiresAt: Date.now() + 3600_000 })
1060
+ );
1061
+
1062
+ await store.bulkSet(
1063
+ [{ key: 'item:1' }, { key: 'item:2' }],
1064
+ [{ key: 'item:1', value: { x: 1 } }, { key: 'item:2', value: { x: 2 } }]
1065
+ );
1066
+
1067
+ const items = await store.bulkGet<{ x: number }>(['item:1', 'item:2']);
1068
+
1069
+ await store.delete('user:42');
1070
+ await store.bulkDelete(['item:1', 'item:2']);
1071
+ await store.clear();
1072
+
1073
+ // Patch individual fields without rewriting the whole record
1074
+ await store.update('user:42', { tags: ['admin', 'verified'] });
1075
+
1076
+ // Patch multiple records in one transaction
1077
+ await store.bulkUpdate([
1078
+ { key: 'item:1', changes: { tags: ['sale'] } },
1079
+ { key: 'item:2', changes: { tags: ['new'] } },
1080
+ ]);
1081
+ ```
1082
+
1083
+ ```typescript
1084
+ import { PersistentStore, defaultMetadataFieldDefTemplate } from '@actdim/utico/store/persistentStore';
1085
+ import type { MetadataRecord } from '@actdim/utico/store/storeContracts';
1086
+
1087
+ // --- Custom metadata with indexed fields and typed queries ---
1088
+
1089
+ type ArticleMetadata = MetadataRecord & {
1090
+ author: string;
1091
+ publishedAt: number;
1092
+ score: number;
1093
+ };
1094
+
1095
+ const store = await PersistentStore.open<ArticleMetadata>(
1096
+ 'articles',
1097
+ [...defaultMetadataFieldDefTemplate, 'author', 'publishedAt', 'score']
1098
+ // TypeScript only accepts valid FieldDef<keyof ArticleMetadata> entries
1099
+ );
1100
+
1101
+ await store.set(
1102
+ { key: 'post:1', author: 'Alice', publishedAt: Date.now(), score: 42 },
1103
+ '<p>Content here</p>'
1104
+ );
1105
+
1106
+ // Index-based query — fast, uses IndexedDB index directly
1107
+ const topPosts = await store.where('score').above(10).toArray();
1108
+
1109
+ // Range query on a date field
1110
+ const recent = await store
1111
+ .where('publishedAt')
1112
+ .above(Date.now() - 7 * 86400_000)
1113
+ .toArray();
1114
+
1115
+ // In-memory filter + pagination
1116
+ const page = await store
1117
+ .query()
1118
+ .filter(m => m.author === 'Alice')
1119
+ .offset(0)
1120
+ .limit(10)
1121
+ .toArray('publishedAt', 'desc');
1122
+ ```
1123
+
1124
+ ---
1125
+
1126
+ ### cache — Persistent Cache
1127
+
1128
+ **Imports:**
1129
+
1130
+ - `@actdim/utico/cache/persistentCache` — `PersistentCache`, `CacheOptions`, `PersistentCacheOptions`
1131
+ - `@actdim/utico/cache/cacheContracts` — `CacheMetadataRecord`
1132
+
1133
+ 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.
1134
+
1135
+ #### Types
1136
+
1137
+ | Type | Description |
1138
+ |------|-------------|
1139
+ | `CacheMetadataRecord` | Extends `MetadataRecord` with `slidingExpiration`, `absoluteExpiration`, and `expiresAt` |
1140
+ | `CacheOptions` | Per-entry expiration options (see below) |
1141
+ | `PersistentCacheOptions` | Cache-level options: `cleanupTimeout` (ms between background cleanup runs) |
1142
+ | `CacheEvictionEvent` | `{ records: CacheMetadataRecord[] }` — payload of the `"evict"` event |
1143
+
1144
+ #### `CacheOptions`
1145
+
1146
+ | Field | Type | Description |
1147
+ |-------|------|-------------|
1148
+ | `slidingExpiration` | `number` (ms) | Extends `expiresAt` by this duration on every `get()`. Recommended: combined with `absoluteExpiration` as a cap. |
1149
+ | `absoluteExpiration` | `Date \| number` | Hard deadline — `expiresAt` is never pushed past this value. |
1150
+ | `ttl` | `number \| { seconds?, minutes?, hours? }` | Sets `absoluteExpiration` relative to the creation time. |
1151
+
1152
+ #### Static Methods
1153
+
1154
+ | Method | Description |
1155
+ |--------|-------------|
1156
+ | `PersistentCache.open(name, options?)` | Open or create a named cache. Returns a `PersistentCache` instance. |
1157
+ | `PersistentCache.exists(name)` | Check if a named cache database exists. |
1158
+ | `PersistentCache.delete(name)` | Delete a named cache database. |
1159
+
1160
+ #### Instance Methods
1161
+
1162
+ | Method | Description |
1163
+ |--------|-------------|
1164
+ | `get<TValue>(key)` | Get an item and update `accessedAt` (and `expiresAt` if `slidingExpiration` is set). |
1165
+ | `set(metadata, value, options)` | Create or overwrite an item with expiration options. Auto-generates a UUID key if `metadata.key` is absent. |
1166
+ | `getOrSet(metadata, factory, options)` | Return the existing item or create it via `factory`. `metadata.key` is required. |
1167
+ | `bulkGet<TValue>(keys)` | Get multiple items by key. |
1168
+ | `bulkSet(metadataRecords, dataRecords, optionsProvider)` | Insert multiple items. `optionsProvider` is called per-record to produce `CacheOptions`. |
1169
+ | `contains(key)` | Check if a key exists. |
1170
+ | `getKeys()` | Return all stored keys. |
1171
+ | `delete(key)` | Delete a single item. |
1172
+ | `bulkDelete(keys)` | Delete multiple items. |
1173
+ | `clear()` | Delete all items. |
1174
+ | `deleteExpired(ts?)` | Evict entries whose `expiresAt < ts` (defaults to `Date.now()`). Fires the `"evict"` event if anything was removed. |
1175
+ | `[Symbol.dispose]()` | Cancel the background cleanup timer and close the database. |
1176
+
1177
+ #### Events
1178
+
1179
+ `PersistentCache` extends `StructEventTarget`. Subscribe with `addEventListener`.
1180
+
1181
+ | Event | Detail type | Description |
1182
+ |-------|-------------|-------------|
1183
+ | `"evict"` | `{ records: CacheMetadataRecord[] }` | Fired after `deleteExpired` removes at least one entry. |
1184
+
1185
+ #### Usage Examples
1186
+
1187
+ ```typescript
1188
+ import { PersistentCache } from '@actdim/utico/cache/persistentCache';
1189
+
1190
+ // --- Open a cache ---
1191
+
1192
+ const cache = await PersistentCache.open('my-cache');
1193
+
1194
+ // --- set / get ---
1195
+
1196
+ await cache.set({ key: 'user:42' }, { name: 'Alice' }, {});
1197
+
1198
+ const item = await cache.get<{ name: string }>('user:42');
1199
+ item.metadata.key // 'user:42'
1200
+ item.metadata.createdAt // timestamp set automatically
1201
+ item.data.value.name // 'Alice'
1202
+
1203
+ // --- Auto-generated key ---
1204
+
1205
+ const meta = {}; // no key provided
1206
+ await cache.set(meta, { x: 1 }, {});
1207
+ meta.key; // UUID filled in by set()
1208
+
1209
+ // --- getOrSet ---
1210
+
1211
+ const item2 = await cache.getOrSet(
1212
+ { key: 'session:abc' },
1213
+ () => ({ token: crypto.randomUUID() }),
1214
+ {}
1215
+ );
1216
+
1217
+ // --- Sliding expiration (renewed on every get) ---
1218
+
1219
+ await cache.set({ key: 'live' }, 'data', { slidingExpiration: 30_000 });
1220
+ // expiresAt = now + 30 s; reset to now + 30 s on each get()
1221
+
1222
+ // --- Sliding expiration with absolute cap ---
1223
+
1224
+ await cache.set({ key: 'bounded' }, 'data', {
1225
+ slidingExpiration: 5 * 60_000, // renew up to 5 min on each get
1226
+ absoluteExpiration: Date.now() + 3_600_000, // but never past 1 hour from now
1227
+ });
1228
+
1229
+ // --- Manual eviction ---
1230
+
1231
+ await cache.deleteExpired(); // uses Date.now()
1232
+ await cache.deleteExpired(Date.now() + 60_000); // treat everything expiring in the next minute as expired
1233
+
1234
+ // --- Eviction event ---
1235
+
1236
+ cache.addEventListener('evict', (e) => {
1237
+ console.log('Evicted:', e.detail.records.map(r => r.key));
1238
+ });
1239
+
1240
+ // --- Cleanup ---
1241
+
1242
+ cache[Symbol.dispose](); // stops background timer, closes DB
1243
+ // or:
1244
+ using c = await PersistentCache.open('temp'); // auto-disposed at block exit (TS 5.2+)
1245
+ ```
1246
+
1247
+ ---
1248
+
1249
+ ## License
1250
+
1251
+ Proprietary — © Pavel Borodaev