@actdim/utico 1.1.1 → 1.1.3

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 (68) hide show
  1. package/README.md +832 -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.d.ts +11 -11
  9. package/dist/cache/cacheContracts.d.ts.map +1 -1
  10. package/dist/cache/cacheContracts.es.js.map +1 -1
  11. package/dist/cache/memoryCache.es.js.map +1 -1
  12. package/dist/cache/persistentCache.d.ts +17 -17
  13. package/dist/cache/persistentCache.d.ts.map +1 -1
  14. package/dist/cache/persistentCache.es.js +56 -54
  15. package/dist/cache/persistentCache.es.js.map +1 -1
  16. package/dist/dataFormats.es.js.map +1 -1
  17. package/dist/dateTimeDataFormat.es.js.map +1 -1
  18. package/dist/decorators.d.ts +2 -0
  19. package/dist/decorators.d.ts.map +1 -0
  20. package/dist/decorators.es.js +20 -0
  21. package/dist/decorators.es.js.map +1 -0
  22. package/dist/gfx/canvasUtils.d.ts +4 -5
  23. package/dist/gfx/canvasUtils.d.ts.map +1 -1
  24. package/dist/gfx/canvasUtils.es.js +77 -84
  25. package/dist/gfx/canvasUtils.es.js.map +1 -1
  26. package/dist/gfx/color.es.js.map +1 -1
  27. package/dist/i18n/cultures.es.js.map +1 -1
  28. package/dist/i18n/enUsCulture.es.js.map +1 -1
  29. package/dist/index.es.js.map +1 -1
  30. package/dist/math.es.js.map +1 -1
  31. package/dist/metadata.d.ts +3 -2
  32. package/dist/metadata.d.ts.map +1 -1
  33. package/dist/metadata.es.js +23 -17
  34. package/dist/metadata.es.js.map +1 -1
  35. package/dist/patterns.es.js.map +1 -1
  36. package/dist/store/dataStore.d.ts +33 -59
  37. package/dist/store/dataStore.d.ts.map +1 -1
  38. package/dist/store/dataStore.es.js +151 -145
  39. package/dist/store/dataStore.es.js.map +1 -1
  40. package/dist/store/persistentStore.d.ts +7 -6
  41. package/dist/store/persistentStore.d.ts.map +1 -1
  42. package/dist/store/persistentStore.es.js +15 -14
  43. package/dist/store/persistentStore.es.js.map +1 -1
  44. package/dist/store/storeContracts.d.ts +52 -24
  45. package/dist/store/storeContracts.d.ts.map +1 -1
  46. package/dist/store/storeContracts.es.js +2 -2
  47. package/dist/store/storeContracts.es.js.map +1 -1
  48. package/dist/store/storeDb.es.js +38 -38
  49. package/dist/store/storeDb.es.js.map +1 -1
  50. package/dist/stringCore.d.ts +1 -1
  51. package/dist/stringCore.d.ts.map +1 -1
  52. package/dist/stringCore.es.js +5 -5
  53. package/dist/stringCore.es.js.map +1 -1
  54. package/dist/structEvent.d.ts +4 -3
  55. package/dist/structEvent.d.ts.map +1 -1
  56. package/dist/structEvent.es.js +16 -9
  57. package/dist/structEvent.es.js.map +1 -1
  58. package/dist/typeCore.es.js.map +1 -1
  59. package/dist/typeUtils.d.ts +7 -6
  60. package/dist/typeUtils.d.ts.map +1 -1
  61. package/dist/typeUtils.es.js +38 -34
  62. package/dist/typeUtils.es.js.map +1 -1
  63. package/dist/utils.d.ts +4 -3
  64. package/dist/utils.d.ts.map +1 -1
  65. package/dist/utils.es.js +70 -61
  66. package/dist/utils.es.js.map +1 -1
  67. package/dist/watchable.es.js.map +1 -1
  68. package/package.json +7 -4
package/README.md CHANGED
@@ -1,11 +1,838 @@
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
+ - [StructEvent — Typed DOM Events](#structevent--typed-dom-events)
12
+ - [watchable — Promise & Function Tracking](#watchable--promise--function-tracking)
13
+ - [asyncMutex — Async Mutual Exclusion](#asyncmutex--async-mutual-exclusion)
14
+ - [store — Structured Persistence](#store--structured-persistence)
15
+ - [License](#license)
8
16
 
9
- ## Structured persistence
17
+ ---
10
18
 
11
- ## Useful type extensions
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @actdim/utico
23
+ # or
24
+ pnpm add @actdim/utico
25
+ ```
26
+
27
+ **Peer dependencies** (install only what you use):
28
+
29
+ ```bash
30
+ pnpm add dexie uuid moment
31
+ ```
32
+
33
+ > `dexie` and `uuid` are required for the `store` module. `moment` is required for date/time formatting utilities.
34
+
35
+ ---
36
+
37
+ ## Modules
38
+
39
+ ### typeCore — Expressive Type Composition
40
+
41
+ **Import:** `@actdim/utico/typeCore`
42
+
43
+ A comprehensive set of TypeScript utility types and helper functions for advanced type manipulation.
44
+
45
+ #### Types
46
+
47
+ | Type | Description |
48
+ | ---------------------------- | ----------------------------------------------------------------- |
49
+ | `Skip<T, K>` | A more useful version of `Omit` — removes keys `K` from `T` |
50
+ | `Filter<T, V>` | Keeps only properties of `T` whose values extend `V` |
51
+ | `Diff<T, U>` | Properties in `T` that are not in `U` |
52
+ | `StrictDiff<T, U>` | Properties in `T` that differ from `U` (by type) |
53
+ | `CommonPart<T, U>` | Mathematical intersection — shared properties with types from `T` |
54
+ | `CommonKeys<T, U>` | Union of keys shared by `T` and `U` |
55
+ | `UnionToIntersection<U>` | Converts a union type to an intersection type |
56
+ | `ValueUnion<T>` | Union of all property value types in `T` |
57
+ | `KeyPath<T>` | Dot-notation path strings for all nested properties of `T` |
58
+ | `KeyPathValue<T, P>` | Value type at a given `KeyPath` `P` in `T` |
59
+ | `KeyPathValueMap<T>` | Partial map of `KeyPath` strings to their values |
60
+ | `OneOfType<T>` | Discriminated union — exactly one property of `T` is set |
61
+ | `Weaken<T, K>` | Replaces specified keys in `T` with `any` |
62
+ | `Mutable<T>` | Removes `readonly` from all properties |
63
+ | `Overwrite<Base, Overrides>` | Merges types, with `Overrides` taking precedence |
64
+ | `Func<TArgs, T>` | Generic function type |
65
+ | `Action<TArgs>` | Function returning `void` |
66
+ | `AsyncFunc<TArgs, T>` | Async function type |
67
+ | `Executor<T>` | Function returning `T` or `Promise<T>` |
68
+ | `MaybePromise<T>` | `T` or `PromiseLike<T>` |
69
+ | `Factory<T, TArgs>` | Factory function type |
70
+ | `IProvider<TFactory>` | Object with a `get` factory method |
71
+ | `AddPrefix<T, P>` | Adds prefix `P` to a string or to all keys of an object |
72
+ | `AddSuffix<T, S>` | Adds suffix `S` to a string or to all keys of an object |
73
+ | `RemovePrefix<T, P>` | Removes prefix `P` from a string or all keys |
74
+ | `RemoveSuffix<T, S>` | Removes suffix `S` from a string or all keys |
75
+ | `ToUpper<T>` | Uppercases a string literal or all keys of an object |
76
+ | `ToLower<T>` | Lowercases a string literal or all keys of an object |
77
+ | `Constructor` | `new (...args: any[]) => any` |
78
+ | `ConstructorClass<T>` | Extracts the instance type from a constructor |
79
+ | `CallableConstructor<T>` | Constructor callable with or without `new` |
80
+ | `IF<Condition, Then, Else>` | Conditional type alias |
81
+
82
+ #### Functions
83
+
84
+ | Function | Description |
85
+ | ----------------------------- | --------------------------------------------------------------------- |
86
+ | `getPrefixer(prefix)` | Returns `(value: string) => string` that prepends `prefix` |
87
+ | `getValuePrefixer<T>(prefix)` | Returns a function that prefixes all values of a string-valued object |
88
+ | `getKeyPrefixer<T>(prefix)` | Returns a function that prefixes all keys of an object |
89
+
90
+ #### Usage Examples
91
+
92
+ ```typescript
93
+ import type {
94
+ Skip,
95
+ Filter,
96
+ Diff,
97
+ CommonPart,
98
+ KeyPath,
99
+ KeyPathValue,
100
+ KeyPathValueMap,
101
+ OneOfType,
102
+ Mutable,
103
+ Overwrite,
104
+ Func,
105
+ Executor,
106
+ MaybePromise,
107
+ AddPrefix,
108
+ RemovePrefix,
109
+ } from '@actdim/utico/typeCore';
110
+ import { getPrefixer, getValuePrefixer, getKeyPrefixer } from '@actdim/utico/typeCore';
111
+
112
+ // Skip — remove specific keys
113
+ type User = { id: number; name: string; password: string };
114
+ type PublicUser = Skip<User, 'password'>;
115
+ // => { id: number; name: string }
116
+
117
+ // Filter — keep only properties of a given type
118
+ type StringProps = Filter<User, string>;
119
+ // => { name: string; password: string }
120
+
121
+ // Diff — remove overlapping keys
122
+ type A = { x: number; y: number; z: number };
123
+ type B = { y: number };
124
+ type OnlyInA = Diff<A, B>;
125
+ // => { x: number; z: number }
126
+
127
+ // CommonPart — shared properties
128
+ type Common = CommonPart<{ a: number; b: string }, { b: string; c: boolean }>;
129
+ // => { b: string }
130
+
131
+ // KeyPath — deeply nested dot-notation paths
132
+ type Config = { server: { host: string; port: number }; debug: boolean };
133
+ type Paths = KeyPath<Config>;
134
+ // => "server" | "debug" | "server.host" | "server.port"
135
+
136
+ type HostType = KeyPathValue<Config, 'server.host'>;
137
+ // => string
138
+
139
+ // Partial deep-update patch object
140
+ const patch: KeyPathValueMap<Config> = { 'server.port': 8080 };
141
+
142
+ // OneOfType — exactly one property set
143
+ type Payload = OneOfType<{ text: string; html: string; json: object }>;
144
+ // valid: { text: "hello", html: null, json: null }
145
+ // valid: { text: null, html: "<b>hi</b>", json: null }
146
+
147
+ // Mutable — remove readonly
148
+ type ReadonlyPoint = { readonly x: number; readonly y: number };
149
+ type Point = Mutable<ReadonlyPoint>;
150
+ // => { x: number; y: number }
151
+
152
+ // Overwrite — merge with override
153
+ type Base = { id: number; name: string; active: boolean };
154
+ type Updated = Overwrite<Base, { active: string }>;
155
+ // => { id: number; name: string; active: string }
156
+
157
+ // AddPrefix / RemovePrefix on object keys
158
+ type Prefixed = AddPrefix<{ name: string; age: number }, 'user_'>;
159
+ // => { user_name: string; user_age: number }
160
+
161
+ type Unprefixed = RemovePrefix<Prefixed, 'user_'>;
162
+ // => { name: string; age: number }
163
+
164
+ // getPrefixer
165
+ const withNs = getPrefixer('app:');
166
+ withNs('config'); // => "app:config"
167
+
168
+ // getValuePrefixer
169
+ const prefixValues = getValuePrefixer<{ a: string; b: string }>('v_');
170
+ prefixValues({ a: 'foo', b: 'bar' });
171
+ // => { a: 'v_foo', b: 'v_bar' }
172
+
173
+ // getKeyPrefixer
174
+ const prefixKeys = getKeyPrefixer<{ foo: 1; bar: 2 }>('x_');
175
+ prefixKeys({ foo: 1, bar: 2 });
176
+ // => { x_foo: 1, x_bar: 2 }
177
+ ```
178
+
179
+ ---
180
+
181
+ ### typeUtils — Runtime Type Utilities
182
+
183
+ **Import:** `@actdim/utico/typeUtils`
184
+
185
+ Runtime helpers that complement the pure-type utilities in `typeCore`: typed object access,
186
+ property-name reflection, constructor binding, proxies, enums, and JSON helpers.
187
+
188
+ ---
189
+
190
+ #### Constructor Utilities
191
+
192
+ These utilities solve a common TypeScript problem: creating a reusable, pre-typed alias for a
193
+ generic class without repeating its type arguments everywhere.
194
+
195
+ **Background.** Consider `StructEvent<TStruct, TTarget>` — a generic typed event class
196
+ (see [StructEvent](#structevent--typed-dom-events)).
197
+ Inside `PersistentCache` you want to work with
198
+ `StructEvent<PersistentCacheEventStruct, PersistentCache>` as if it were its own named type.
199
+ TypeScript offers four ways to achieve this; each has different trade-offs.
200
+
201
+ ---
202
+
203
+ ##### `typed()`
204
+
205
+ ```ts
206
+ function typed<TCtor extends Constructor>(ctor: TCtor): CallableConstructor<TCtor>
207
+ ```
208
+
209
+ Narrows a generic constructor to a pre-typed alias using a TypeScript
210
+ **Instantiation Expression** (TS 4.7+). Zero runtime cost — returns `ctor` as-is.
211
+ The type arguments are bound at the call site by passing `MyClass<A, B>` as a *value expression*
212
+ (without `new`), so the inferred constructor already has the concrete types locked in before
213
+ `typed` is called.
214
+
215
+ ```ts
216
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
217
+
218
+ const evt = new PersistentCacheEvent("evict", {
219
+ detail: { records },
220
+ target: this,
221
+ cancelable: true,
222
+ });
223
+ ```
224
+
225
+ ---
226
+
227
+ ##### `createConstructor()`
228
+
229
+ ```ts
230
+ function createConstructor<TConstructor extends Constructor>(
231
+ type: TConstructor
232
+ ): CallableConstructor<TConstructor>
233
+ ```
234
+
235
+ Same as `typed()` — binds generic type arguments via an Instantiation Expression — but also makes
236
+ the constructor **callable without `new`**. It wraps the class in a plain function that forwards
237
+ all arguments, and patches `prototype` so `instanceof` still works correctly.
238
+
239
+ ```ts
240
+ const PersistentCacheEvent = createConstructor(StructEvent<PersistentCacheEventStruct, PersistentCache>);
241
+
242
+ // no new required:
243
+ const evt = PersistentCacheEvent("evict", { detail: { records }, target: this });
244
+ ```
245
+
246
+ > For primitive-backed types (`String`, `Number`) the original constructor is returned unchanged,
247
+ > since they are already callable without `new`.
248
+
249
+ ---
250
+
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
+ #### Object / Key Utilities
309
+
310
+ | Function | Description |
311
+ |----------|-------------|
312
+ | `keysOf(obj)` | Typed `Object.keys` — returns `(keyof T)[]` instead of `string[]` |
313
+ | `keyOf<T>(key)` | Returns a property name literal narrowed to `keyof T`. No object required — useful for building typed key references |
314
+ | `nameOf<T>(f)` | Extracts a property name from a lambda `x => x.prop` at runtime via `Proxy` |
315
+ | `entry(obj, name, caseInsensitive?)` | Looks up a key (optionally case-insensitive) and returns `[resolvedKey, value]` |
316
+
317
+ ```ts
318
+ keysOf({ a: 1, b: 2 }) // => ["a", "b"] typed as ("a" | "b")[]
319
+
320
+ keyOf<CacheMetadataRecord>("expiresAt") // => "expiresAt" — typed, no runtime object needed
321
+
322
+ nameOf<User>(x => x.email) // => "email"
323
+ ```
324
+
325
+ ---
326
+
327
+ #### Constraint Helpers
328
+
329
+ | Function | Description |
330
+ |----------|-------------|
331
+ | `satisfies<TShape>()` | Curried constraint: validates `obj` extends `TShape` without widening the inferred type |
332
+ | `strictSatisfies<T>()` | Like `satisfies`, but also rejects objects with extra keys beyond the shape |
333
+
334
+ ```ts
335
+ const opts = satisfies<{ timeout: number }>()({ timeout: 5000, retries: 3 });
336
+ // opts is inferred as { timeout: number; retries: number }, not widened to { timeout: number }
337
+ ```
338
+
339
+ ---
340
+
341
+ #### Assignment Utilities
342
+
343
+ | Function | Description |
344
+ |----------|-------------|
345
+ | `assignWith(dst, src, callback?)` | Conditional assign: iterates `src` keys, calls `callback(key, value, set)` to control each assignment |
346
+ | `update(dst, src, props?)` | Typed assign: `src` must be `Partial<T>`; optional `props` list limits which keys are copied |
347
+ | `copy(src, dst, props?)` | Like `update` but with source and destination swapped |
348
+
349
+ ---
350
+
351
+ #### Path Utilities
352
+
353
+ | Function | Description |
354
+ |----------|-------------|
355
+ | `getPropertyPath<T>(expr)` | Captures a property access chain from a lambda as `(string \| number \| symbol)[]` via recursive proxy |
356
+ | `combinePropertyPath(path)` | Serialises a path array into bracket-notation: `["nested"]["0"]["name"]` |
357
+
358
+ ```ts
359
+ getPropertyPath<Config>(x => x.server.port) // => ["server", "port"]
360
+ combinePropertyPath(["server", "port"]) // => '["server"]["port"]'
361
+ ```
362
+
363
+ ---
364
+
365
+ #### Proxy Utilities
366
+
367
+ | Function | Description |
368
+ |----------|-------------|
369
+ | `proxify<T>(source)` | Lazy proxy: forwards every get/set to `source()` evaluated at access time |
370
+ | `toReadOnly<T>(obj, throwOnSet?)` | Deep read-only proxy; silently ignores writes (or throws if `throwOnSet: true`). Toggle with the `[$lock]` symbol |
371
+ | `createDeepProxy<T>(target, handler)` | Deep-change proxy: `handler.set` and `handler.deleteProperty` receive the full `DeepPropertyKey` path |
372
+
373
+ ---
374
+
375
+ #### JSON Utilities
376
+
377
+ | Function | Description |
378
+ |----------|-------------|
379
+ | `orderedStringify(obj, keyCompareFn?, replacer?, space?)` | Stable JSON serialisation: sorts object keys recursively before stringifying |
380
+ | `jsonEquals(obj1, obj2)` | Structural equality via `orderedStringify` |
381
+ | `jsonClone<T>(obj)` | Deep clone via `JSON.parse(JSON.stringify(obj))` — for plain JSON-serialisable data |
382
+
383
+ ```ts
384
+ jsonEquals({ b: 2, a: 1 }, { a: 1, b: 2 }) // => true (key order doesn't matter)
385
+ ```
386
+
387
+ ---
388
+
389
+ #### Enum Utilities
390
+
391
+ | Function | Description |
392
+ |----------|-------------|
393
+ | `getEnumKeys<T>(enumType)` | Returns the string keys of a TS enum, filtering reverse-mapping numeric keys |
394
+ | `getEnumValues<T>(enumType)` | Returns the values of a TS enum |
395
+ | `getEnumValue<T>(enumType, name, defaultValue)` | Looks up an enum member by name; returns `defaultValue` when missing |
396
+
397
+ ```ts
398
+ enum Color { Red = 0, Green = 1, Blue = 2 }
399
+
400
+ getEnumKeys(Color) // => ["Red", "Green", "Blue"]
401
+ getEnumValues(Color) // => [0, 1, 2]
402
+ getEnumValue(Color, "Green", Color.Red) // => 1
403
+ getEnumValue(Color, "Purple", Color.Red) // => 0 (default)
404
+ ```
405
+
406
+ ---
407
+
408
+ ### StructEvent — Typed DOM Events
409
+
410
+ **Import:** `@actdim/utico/structEvent`
411
+
412
+ `StructEvent` and `StructEventTarget` bring the standard DOM `EventTarget` / `CustomEvent` API
413
+ into TypeScript's type system. You describe every event your class can emit as a **struct** — a
414
+ plain object type where keys are event names and values are the `detail` payload types — and the
415
+ compiler enforces correct event names, `detail` shapes, and listener signatures everywhere.
416
+
417
+ #### Classes
418
+
419
+ | Class | Description |
420
+ |-------|-------------|
421
+ | `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. |
422
+ | `StructEventTarget<TStruct>` | `EventTarget` subclass with typed overloads for `addEventListener`, `removeEventListener`, `dispatchEvent`, and `hasEventListener`. |
423
+
424
+ #### Constructor: `StructEvent`
425
+
426
+ ```ts
427
+ new StructEvent<TStruct, TTarget, TType>(
428
+ type: TType,
429
+ eventInitDict?: CustomEventInit<TStruct[TType]> & { target: TTarget }
430
+ )
431
+ ```
432
+
433
+ #### Methods: `StructEventTarget`
434
+
435
+ | Method | Description |
436
+ |--------|-------------|
437
+ | `addEventListener<K>(type, listener, options?)` | Adds a typed listener; `listener` receives `StructEvent<TStruct, this, K>` |
438
+ | `removeEventListener<K>(type, listener, options?)` | Removes a previously added typed listener |
439
+ | `dispatchEvent<K>(event)` | Dispatches a `StructEvent`; TypeScript rejects events whose struct does not match |
440
+ | `hasEventListener<K>(type, listener)` | Returns `true` if the exact listener is currently registered |
441
+
442
+ #### Usage Examples
443
+
444
+ ```typescript
445
+ import { StructEvent, StructEventTarget } from '@actdim/utico/structEvent';
446
+ import { typed } from '@actdim/utico/typeUtils';
447
+
448
+ // --- 1. Define the event struct ---
449
+ // Keys = event names, values = detail payload types
450
+
451
+ type PersistentCacheEventStruct = {
452
+ evict: { records: CacheMetadataRecord[] };
453
+ };
454
+
455
+ // --- 2. Extend StructEventTarget ---
456
+
457
+ class PersistentCache extends StructEventTarget<PersistentCacheEventStruct> {
458
+
459
+ // --- Dispatching: Option A — inline `this` type (zero boilerplate) ---
460
+ //
461
+ // Inside a class method `this` is a polymorphic type, so you can pass it
462
+ // directly as the second type argument. TypeScript infers "evict",
463
+ // checks `detail` against the struct, and verifies `target`.
464
+
465
+ async deleteExpiredA() {
466
+ const records = await this.fetchExpired();
467
+
468
+ this.dispatchEvent(
469
+ new StructEvent<PersistentCacheEventStruct, this>("evict", {
470
+ detail: { records },
471
+ target: this,
472
+ cancelable: true,
473
+ })
474
+ );
475
+ }
476
+
477
+ // --- Dispatching: Option B — pre-bound alias with typed() (recommended for reuse) ---
478
+ //
479
+ // Bind the constructor once at module scope (or as a static field).
480
+ // See the Constructor Utilities section in typeUtils for all four
481
+ // binding strategies (subclass, manual cast, typed(), createConstructor()).
482
+
483
+ async deleteExpiredB() {
484
+ const records = await this.fetchExpired();
485
+
486
+ this.dispatchEvent(
487
+ new PersistentCacheEvent("evict", {
488
+ detail: { records },
489
+ target: this,
490
+ cancelable: true,
491
+ })
492
+ );
493
+ }
494
+ }
495
+
496
+ // Alias created once at module scope — equivalent to a named type for
497
+ // StructEvent<PersistentCacheEventStruct, PersistentCache>
498
+ const PersistentCacheEvent = typed(StructEvent<PersistentCacheEventStruct, PersistentCache>);
499
+
500
+ // --- 3. Listening to typed events ---
501
+
502
+ const cache = await PersistentCache.open("my-cache");
503
+
504
+ cache.addEventListener("evict", (e) => {
505
+ // e.detail → { records: CacheMetadataRecord[] } (typed)
506
+ // e.target → PersistentCache (typed)
507
+ console.log("Evicted records:", e.detail.records);
508
+ });
509
+ ```
510
+
511
+ **All four ways to create the bound alias** are shown in the
512
+ [Constructor Utilities](#constructor-utilities) section of typeUtils, using exactly
513
+ `StructEvent<PersistentCacheEventStruct, PersistentCache>` as the running example.
514
+
515
+ ---
516
+
517
+ ### watchable — Promise & Function Tracking
518
+
519
+ **Import:** `@actdim/utico/watchable`
520
+
521
+ Track the execution state of promises and functions — useful for loading indicators, UI state, and conditional logic without `try/catch` boilerplate.
522
+
523
+ #### Types
524
+
525
+ | Type | Description |
526
+ | ------------------------- | ---------------------------------------------------------------- |
527
+ | `PromiseStatus` | `"pending" \| "fulfilled" \| "rejected"` |
528
+ | `WatchablePromise<T>` | `PromiseLike<T>` extended with `status`, `settled`, and `result` |
529
+ | `WatchableFunc<TArgs, T>` | Function extended with an `executing` flag |
530
+
531
+ #### Functions
532
+
533
+ | Function | Signature | Description |
534
+ | ------------- | ------------------------------------------------- | --------------------------------------------- |
535
+ | `watch` | `(fn: Executor<T>) => WatchablePromise<T>` | Wraps an executor in a trackable promise |
536
+ | `toWatchable` | `(fn: Func<TArgs, T>) => WatchableFunc<TArgs, T>` | Wraps a function to track its execution state |
537
+
538
+ #### Usage Examples
539
+
540
+ ```typescript
541
+ import { watch, toWatchable } from '@actdim/utico/watchable';
542
+
543
+ // --- watch: observable promise ---
544
+
545
+ const request = watch(async () => {
546
+ const res = await fetch('/api/data');
547
+ return res.json();
548
+ });
549
+
550
+ console.log(request.status); // "pending"
551
+ console.log(request.settled); // false
552
+
553
+ await request;
554
+
555
+ console.log(request.status); // "fulfilled" or "rejected"
556
+ console.log(request.settled); // true
557
+ console.log(request.result); // the resolved value (or undefined if rejected)
558
+
559
+ // Useful in UI: show spinner while pending
560
+ setInterval(() => {
561
+ if (!request.settled) showSpinner();
562
+ else hideSpinner();
563
+ }, 100);
564
+
565
+ // --- toWatchable: track function execution ---
566
+
567
+ const saveData = toWatchable(async (data: object) => {
568
+ await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
569
+ });
570
+
571
+ console.log(saveData.executing); // false
572
+
573
+ saveData({ name: 'Alice' }); // call does not need to be awaited to check state
574
+
575
+ console.log(saveData.executing); // true (async function is running)
576
+
577
+ // Prevent double-submission
578
+ const submitButton = document.querySelector('button')!;
579
+ submitButton.addEventListener('click', () => {
580
+ if (saveData.executing) return; // guard against concurrent calls
581
+ saveData({ name: 'Bob' });
582
+ });
583
+ ```
584
+
585
+ ---
586
+
587
+ ### asyncMutex — Async Mutual Exclusion
588
+
589
+ **Import:** `@actdim/utico/asyncMutex`
590
+
591
+ A lightweight async mutex that serializes concurrent async operations. Prevents race conditions when accessing shared resources.
592
+
593
+ #### Class: `AsyncMutex`
594
+
595
+ | Method | Signature | Description |
596
+ | ---------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
597
+ | `lock` | `(timeoutMs?: number) => Promise<() => void>` | Acquires the lock; returns an `unlock` function. Throws on timeout. |
598
+ | `tryLock` | `() => (() => void) \| null` | Non-blocking acquire; returns `unlock` or `null` if already locked. |
599
+ | `dispatch` | `(fn: Executor<T>, timeoutMs?: number) => Promise<T>` | Acquires lock, runs `fn`, releases lock — the recommended pattern. |
600
+
601
+ #### Usage Examples
602
+
603
+ ```typescript
604
+ import { AsyncMutex } from '@actdim/utico/asyncMutex';
605
+
606
+ const mutex = new AsyncMutex();
607
+
608
+ // --- dispatch: the simplest pattern ---
609
+
610
+ async function updateCounter() {
611
+ return mutex.dispatch(async () => {
612
+ const current = await db.get('counter');
613
+ await db.set('counter', current + 1);
614
+ return current + 1;
615
+ });
616
+ }
617
+
618
+ // Safe to call concurrently — operations are serialized
619
+ await Promise.all([updateCounter(), updateCounter(), updateCounter()]);
620
+
621
+ // --- lock / unlock: manual control ---
622
+
623
+ async function criticalSection() {
624
+ const unlock = await mutex.lock();
625
+ try {
626
+ await doWork();
627
+ } finally {
628
+ unlock(); // always release!
629
+ }
630
+ }
631
+
632
+ // --- lock with timeout ---
633
+
634
+ async function timedSection() {
635
+ let unlock: (() => void) | undefined;
636
+ try {
637
+ unlock = await mutex.lock(5000); // wait at most 5 seconds
638
+ await doSlowWork();
639
+ } catch (e) {
640
+ if ((e as Error).message === 'Mutex lock timeout') {
641
+ console.warn('Could not acquire lock in time');
642
+ } else {
643
+ throw e;
644
+ }
645
+ } finally {
646
+ unlock?.();
647
+ }
648
+ }
649
+
650
+ // --- tryLock: fire-and-forget, skip if busy ---
651
+
652
+ function syncSnapshot() {
653
+ const unlock = mutex.tryLock();
654
+ if (!unlock) {
655
+ console.log('Already syncing, skipping...');
656
+ return;
657
+ }
658
+ try {
659
+ takeSnapshot();
660
+ } finally {
661
+ unlock();
662
+ }
663
+ }
664
+ ```
665
+
666
+ ---
667
+
668
+ ### store — Structured Persistence
669
+
670
+ **Imports:**
671
+
672
+ - `@actdim/utico/store/storeContracts` — types and interfaces
673
+ - `@actdim/utico/store/persistentStore` — `PersistentStore` (main entry point)
674
+
675
+ Built on [Dexie](https://dexie.org/) (IndexedDB). Uses `AsyncMutex` internally to protect concurrent database access. Transactions are managed automatically — no manual transaction handling needed.
676
+
677
+ #### Core Types
678
+
679
+ | Type / Class | Description |
680
+ |--------------|-------------|
681
+ | `MetadataRecord` | Base metadata: `key`, `createdAt`, `updatedAt`, `tags` |
682
+ | `DataRecord<TValue>` | `{ key: string; value: TValue }` |
683
+ | `StoreItem<T, TValue>` | Combined: `{ metadata?: T; data?: DataRecord<TValue> }` |
684
+ | `ChangeSet<T>` | `{ key: string; changes: KeyPathValueMap<T> }` |
685
+ | `FieldDef<T>` | Index definition for a field of `T`: `"field"`, `"&field"` (unique), `"*field"` (multi-entry), `"++field"` (auto-increment) |
686
+ | `FieldDefTemplate<T>` | `FieldDef<T>[]` — full index schema; TypeScript enforces valid field names and modifier combinations |
687
+ | `OrderDirection` | `"asc" \| "desc"` |
688
+
689
+ #### Class: `PersistentStore<T extends MetadataRecord>`
690
+
691
+ The main entry point for structured persistence. Key features:
692
+
693
+ - **Standard metadata out of the box** — `key`, `createdAt` (auto), `updatedAt` (auto), `tags`
694
+ - **Custom metadata types** — extend `MetadataRecord` with your own fields and pass a generic type parameter
695
+ - **Custom indexed fields** — declare additional indexes via `FieldDefTemplate` to enable fast index-based queries
696
+ - **Type-safe querying** — `where()` accepts only declared indexed fields; value types match the field type
697
+ - **No transaction boilerplate** — all operations run in optimal transactions automatically
698
+
699
+ | Static Method | Description |
700
+ |---------------|-------------|
701
+ | `PersistentStore.open<T>(name, fieldDefTemplate?, options?)` | Open or create a named store. Pass a custom `fieldDefTemplate` when using a custom metadata type. |
702
+ | `PersistentStore.exists(name)` | Check if a named store exists |
703
+ | `PersistentStore.delete(name)` | Delete a named store |
704
+
705
+ #### Interface: `IPersistentStore<T>`
706
+
707
+ | Method | Description |
708
+ |--------|-------------|
709
+ | `open()` | Open the database (called automatically by `PersistentStore.open`) |
710
+ | `getKeys()` | Get all stored keys |
711
+ | `contains(key)` | Check if a key exists |
712
+ | `get<TValue>(key)` | Get a single item by key |
713
+ | `set<TValue>(metadata, value)` | Create or overwrite an item |
714
+ | `getOrSet<TValue>(metadata, factory)` | Get existing or create via factory |
715
+ | `bulkGet<TValue>(keys)` | Get multiple items |
716
+ | `bulkSet<TValue>(metadataRecords, dataRecords)` | Insert multiple items |
717
+ | `delete(key)` | Delete an item |
718
+ | `bulkDelete(keys)` | Delete multiple items |
719
+ | `clear()` | Delete all items |
720
+ | `query<TValue>()` | In-memory filterable/pageable collection over all items |
721
+ | `where<K>(field)` | Start an index-based query on a declared indexed field |
722
+ | `orderBy(field, direction)` | Get items ordered by an indexed field |
723
+ | `distinct(field)` | Get items with unique values of an indexed field |
724
+
725
+ #### Index Schema
726
+
727
+ The field template is an array of `FieldDef` strings. The first entry is always the primary key.
728
+
729
+ | Syntax | Meaning |
730
+ |--------|---------|
731
+ | `"field"` | Regular index |
732
+ | `"&field"` | Unique index |
733
+ | `"*field"` | Multi-entry index (for array-valued fields, e.g. `tags`) |
734
+ | `"++field"` | Auto-increment index |
735
+
736
+ `defaultMetadataFieldDefTemplate` (exported from `persistentStore`) provides the base schema `["&key", "createdAt", "updatedAt", "tags"]`. Spread it when adding custom fields:
737
+
738
+ ```typescript
739
+ [...defaultMetadataFieldDefTemplate, "score", "*categories"]
740
+ ```
741
+
742
+ 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()`.
743
+
744
+ #### `where()` — Index-based Queries
745
+
746
+ `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.
747
+
748
+ | Method | Description |
749
+ |--------|-------------|
750
+ | `equals(value)` | Exact match |
751
+ | `above(value)` | Strictly greater than (numbers, strings, dates) |
752
+ | `aboveOrEqual(value)` | Greater than or equal |
753
+ | `below(value)` | Strictly less than |
754
+ | `belowOrEqual(value)` | Less than or equal |
755
+ | `between(lower, upper)` | Range (inclusive/exclusive) |
756
+ | `anyOf(values[])` | Matches any value in the list |
757
+ | `noneOf(values[])` | Excludes values in the list |
758
+ | `startsWith(prefix)` | String prefix match |
759
+ | `equalsIgnoreCase(value)` | Case-insensitive string match |
760
+
761
+ #### Usage Examples
762
+
763
+ ```typescript
764
+ import { PersistentStore } from '@actdim/utico/store/persistentStore';
765
+
766
+ // --- Basic usage ---
767
+
768
+ const store = await PersistentStore.open('my-app-store');
769
+
770
+ await store.set({ key: 'user:42' }, { name: 'Alice', role: 'admin' });
771
+
772
+ const item = await store.get<{ name: string; role: string }>('user:42');
773
+ console.log(item.metadata?.createdAt); // auto-set on write
774
+ console.log(item.data?.value.name); // "Alice"
775
+
776
+ await store.getOrSet(
777
+ { key: 'session:abc' },
778
+ () => ({ token: crypto.randomUUID(), expiresAt: Date.now() + 3600_000 })
779
+ );
780
+
781
+ await store.bulkSet(
782
+ [{ key: 'item:1' }, { key: 'item:2' }],
783
+ [{ key: 'item:1', value: { x: 1 } }, { key: 'item:2', value: { x: 2 } }]
784
+ );
785
+
786
+ const items = await store.bulkGet<{ x: number }>(['item:1', 'item:2']);
787
+
788
+ await store.delete('user:42');
789
+ await store.bulkDelete(['item:1', 'item:2']);
790
+ await store.clear();
791
+ ```
792
+
793
+ ```typescript
794
+ import { PersistentStore, defaultMetadataFieldDefTemplate } from '@actdim/utico/store/persistentStore';
795
+ import type { MetadataRecord } from '@actdim/utico/store/storeContracts';
796
+
797
+ // --- Custom metadata with indexed fields and typed queries ---
798
+
799
+ type ArticleMetadata = MetadataRecord & {
800
+ author: string;
801
+ publishedAt: number;
802
+ score: number;
803
+ };
804
+
805
+ const store = await PersistentStore.open<ArticleMetadata>(
806
+ 'articles',
807
+ [...defaultMetadataFieldDefTemplate, 'author', 'publishedAt', 'score']
808
+ // TypeScript only accepts valid FieldDef<keyof ArticleMetadata> entries
809
+ );
810
+
811
+ await store.set(
812
+ { key: 'post:1', author: 'Alice', publishedAt: Date.now(), score: 42 },
813
+ '<p>Content here</p>'
814
+ );
815
+
816
+ // Index-based query — fast, uses IndexedDB index directly
817
+ const topPosts = await store.where('score').above(10).toArray();
818
+
819
+ // Range query on a date field
820
+ const recent = await store
821
+ .where('publishedAt')
822
+ .above(Date.now() - 7 * 86400_000)
823
+ .toArray();
824
+
825
+ // In-memory filter + pagination
826
+ const page = await store
827
+ .query()
828
+ .filter(m => m.author === 'Alice')
829
+ .offset(0)
830
+ .limit(10)
831
+ .toArray('publishedAt', 'desc');
832
+ ```
833
+
834
+ ---
835
+
836
+ ## License
837
+
838
+ Proprietary — © Pavel Borodaev