Skip to content

Commit 828c0b6

Browse files
committed
feat!: throw on duplicate keys, add toJSON/size/iterator, configurable fuzzy threshold
BREAKING CHANGE: `detectDuplicateKeys` removed from the public API. `.add()` and `.addTransient()` now throw `DuplicateKeyError` when a key is already registered — silent overwrites are gone. Use `.extend()` or `.scope()` on a built container for intentional overrides. Closes #3, #5, #6, #7, #8, #9, #11, #12, #19. - #19 — `DuplicateKeyError` thrown by `add()`/`addTransient()`; `detectDuplicateKeys` removed - #7 — `Validator` accepts `similarityThreshold` via constructor (default 0.5) - #5 — `container.toJSON()` returns resolved deps; `JSON.stringify(container)` works - #6 — `container.size` returns count of registered providers - #12 — `Symbol.iterator` yields `[key, value]` pairs; `for...of`, spread, `Array.from` - #8 — coercion edge-case tests (`String(c)`, template literals, loose equality) - #9 — `structuredClone(inspect())` compatibility tests - #11 — benchmark suite (`pnpm run bench`) measuring build / resolve / transient / scope / preload - #3 — Deno + Bun usage examples
1 parent 58b03c8 commit 828c0b6

19 files changed

Lines changed: 851 additions & 96 deletions

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ src/
6565
**What lives here:**
6666

6767
- `types.ts` — all interfaces: `IResolver`, `ICycleDetector`, `IDependencyTracker`, `IValidator`, `IContainer`, `Container<T>`, `Factory<T>`, `RESERVED_KEYS`, `AppDeps` (augmentable global interface for cross-module typing).
68-
- `errors.ts`7 error classes (all extend `ContainerError` with `hint` + `details`) + 2 warning types.
68+
- `errors.ts`8 error classes (all extend `ContainerError` with `hint` + `details`) + 2 warning types.
6969
- `lifecycle.ts``OnInit`/`OnDestroy` interfaces + duck-type guards (`hasOnInit`, `hasOnDestroy`).
70-
- `validation.ts``Validator` class (implements `IValidator`), `detectDuplicateKeys`, Levenshtein distance.
70+
- `validation.ts``Validator` class (configurable `similarityThreshold` via constructor), Levenshtein distance.
7171

7272
**Rules:**
7373

@@ -177,8 +177,8 @@ The public API is defined in `src/index.ts`. **If it's not in this list, it's no
177177

178178
Exported:
179179

180-
- **Values:** `container()`, `ContainerBuilder`, `defineModule`, `transient`, `detectDuplicateKeys`.
181-
- **Errors (values):** `ContainerError`, `CircularDependencyError`, `ContainerConfigError`, `FactoryError`, `ProviderNotFoundError`, `ReservedKeyError`, `UndefinedReturnError`, `AsyncInitErrorWarning`, `ScopeMismatchWarning`.
180+
- **Values:** `container()`, `ContainerBuilder`, `defineModule`, `transient`.
181+
- **Errors (values):** `ContainerError`, `CircularDependencyError`, `ContainerConfigError`, `DuplicateKeyError`, `FactoryError`, `ProviderNotFoundError`, `ReservedKeyError`, `UndefinedReturnError`, `AsyncInitErrorWarning`, `ScopeMismatchWarning`.
182182
- **Types:** `OnInit`, `OnDestroy`, `AppDeps`, `Container`, `IContainer`, `Factory`, `ContainerGraph`, `ContainerHealth`, `ContainerWarning`, `ProviderInfo`, `ScopeOptions`, `Module`, `InferModuleDeps`, `InferModuleBuilt`.
183183

184184
Internal (NOT exported): `Resolver`, `CycleDetector`, `DependencyTracker`, `Preloader`, `Disposer`, `Introspection`, `Validator`, `TRANSIENT_MARKER`.

README.md

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -559,27 +559,17 @@ app.health().warnings;
559559

560560
Use `preload()` to surface these as proper errors.
561561

562-
### Duplicate key detection (pre-spread)
562+
### Duplicate keys
563563

564-
`detectDuplicateKeys` operates on **plain factory records** (`Record<string, Factory>`), not on `Module` functions returned by `defineModule()`. Use it when composing pre-built binding objects:
564+
`.add()` and `.addTransient()` throw `DuplicateKeyError` if the key is already registered — no silent overwrites:
565565

566566
```typescript
567-
import { detectDuplicateKeys } from 'inwire';
568-
569-
const authBindings = {
570-
logger: () => new Logger(),
571-
session: () => new Session(),
572-
};
573-
const userBindings = {
574-
logger: () => new Logger(), // duplicate
575-
user: () => new User(),
576-
};
577-
578-
detectDuplicateKeys(authBindings, userBindings);
579-
// ['logger']
567+
container()
568+
.add('logger', () => new ConsoleLogger())
569+
.add('logger', () => new FileLogger()); // throws DuplicateKeyError
580570
```
581571

582-
> For `defineModule()`-based modules, the type system already prevents same-key collisions across `.addModule()` calls at compile timeno runtime check needed.
572+
For **intentional** overrides (test doubles, plugins, environment-specific bindings), use `.extend()` or `.scope()` on a built containerboth are documented override mechanisms.
583573

584574
### All error types
585575

@@ -588,6 +578,7 @@ detectDuplicateKeys(authBindings, userBindings);
588578
| `ContainerError` | Base class for all errors. Every subclass carries `hint` + `details`. |
589579
| `ContainerConfigError` | Non-function value passed to `scope()` / `extend()` deps |
590580
| `ReservedKeyError` | Reserved method name used as a key |
581+
| `DuplicateKeyError` | `.add()` or `.addTransient()` called twice with the same key |
591582
| `ProviderNotFoundError` | Key not registered (with fuzzy suggestion) |
592583
| `CircularDependencyError` | Cycle detected during resolution |
593584
| `UndefinedReturnError` | Factory returned `undefined` |
@@ -620,7 +611,6 @@ detectDuplicateKeys(authBindings, userBindings);
620611
| `ContainerBuilder` | class | Fluent builder class (rarely instantiated directly — `container()` is the entry point). Exported for type-only use and advanced composition. |
621612
| `defineModule<TDeps?>()(fn)` | function | Defines a typed reusable module. See [Modules reference](#modules-reference). |
622613
| `transient(factory)` | function | Marks a factory as transient (for `scope()` / `extend()`). |
623-
| `detectDuplicateKeys(...records)` | function | Returns keys that appear in more than one factory record (`Record<string, Factory>`). |
624614

625615
### Builder methods
626616

@@ -646,6 +636,9 @@ detectDuplicateKeys(authBindings, userBindings);
646636
| `.health()` | Health snapshot + warnings (`ContainerHealth`). |
647637
| `.dispose()` | LIFO `onDestroy()` on all resolved instances. |
648638
| `[Symbol.asyncDispose]()` | Alias of `.dispose()` — enables `await using container = ...` (ES2023). |
639+
| `.size` | `readonly number` — count of registered providers. |
640+
| `.toJSON()` | Plain object of currently resolved (cached) deps. Does **not** trigger lazy resolution. Makes `JSON.stringify(container)` work. |
641+
| `[Symbol.iterator]()` | Yields `[key, value]` pairs for every registered provider. Triggers lazy resolution. Enables `for...of`, spread, `Array.from`. |
649642

650643
### Types
651644

@@ -680,7 +673,7 @@ src/
680673
types/internal.ts # IResolver, ICycleDetector, IDependencyTracker, IValidator
681674
errors.ts # 7 error classes + 2 warnings, each with hint + details
682675
lifecycle.ts # OnInit / OnDestroy (duck-typed)
683-
validation.ts # Validator, detectDuplicateKeys, Levenshtein
676+
validation.ts # Validator (configurable similarity threshold), Levenshtein
684677
infrastructure/ # mechanisms — depends on domain/ only
685678
resolver.ts # lazy resolution, singleton cache, parent chain
686679
cycle-detector.ts # circular dependency detection

benchmarks/resolution.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* inwire — Resolution performance benchmarks
3+
*
4+
* Measures: build time, first resolution (cold), subsequent resolution (cached singleton),
5+
* transient resolution (no cache), scope creation, and preload() time.
6+
*
7+
* Sizes: 10, 100, 1000 providers.
8+
*
9+
* Run: pnpm run bench
10+
*/
11+
12+
import { performance } from 'node:perf_hooks';
13+
import { container, transient } from '../src/index.js';
14+
15+
const SIZES = [10, 100, 1000] as const;
16+
17+
// ── Helpers ──────────────────────────────────────────────────────────────────
18+
19+
function median(values: number[]): number {
20+
const sorted = [...values].sort((a, b) => a - b);
21+
const mid = Math.floor(sorted.length / 2);
22+
return sorted.length % 2 !== 0
23+
? (sorted[mid] ?? 0)
24+
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
25+
}
26+
27+
function fmt(ns: number): string {
28+
if (ns < 1_000) return `${ns.toFixed(1)} ns`;
29+
if (ns < 1_000_000) return `${(ns / 1_000).toFixed(2)} µs`;
30+
return `${(ns / 1_000_000).toFixed(2)} ms`;
31+
}
32+
33+
/** Run fn() for `iters` iterations, return durations in nanoseconds. */
34+
function measure(fn: () => void, iters: number): number[] {
35+
const results: number[] = [];
36+
for (let i = 0; i < iters; i++) {
37+
const start = performance.now();
38+
fn();
39+
results.push((performance.now() - start) * 1_000_000);
40+
}
41+
return results;
42+
}
43+
44+
// ── Container factories ───────────────────────────────────────────────────────
45+
46+
function buildFlat(n: number) {
47+
let b = container() as any;
48+
for (let i = 0; i < n; i++) {
49+
b = b.add(`svc${i}`, () => ({ value: i }));
50+
}
51+
return b;
52+
}
53+
54+
function buildTransient(n: number) {
55+
let b = container() as any;
56+
for (let i = 0; i < n; i++) {
57+
b = b.add(
58+
`svc${i}`,
59+
transient(() => ({ value: i })),
60+
);
61+
}
62+
return b;
63+
}
64+
65+
// ── Benchmark suite ───────────────────────────────────────────────────────────
66+
67+
interface BenchRow {
68+
size: number;
69+
'build (median)': string;
70+
'cold resolve (median)': string;
71+
'cached resolve (median)': string;
72+
'transient resolve (median)': string;
73+
'scope create (median)': string;
74+
'preload (median)': string;
75+
}
76+
77+
async function runForSize(n: number): Promise<BenchRow> {
78+
const BUILD_ITERS = 100;
79+
const RESOLVE_ITERS = 10_000;
80+
const TRANSIENT_ITERS = 1_000;
81+
const SCOPE_ITERS = 100;
82+
const PRELOAD_ITERS = n === 1000 ? 10 : 100;
83+
84+
// ── Build time ──────────────────────────────────────────────────────────────
85+
buildFlat(n).build(); // warm up
86+
const buildTimes = measure(() => buildFlat(n).build(), BUILD_ITERS);
87+
88+
// ── Cold resolution — rebuilds container each time (includes build cost) ───
89+
{
90+
const c = buildFlat(n).build() as any;
91+
void c.svc0; // warm up
92+
}
93+
const coldTimes = measure(() => {
94+
const c = buildFlat(n).build() as any;
95+
void c.svc0;
96+
}, BUILD_ITERS);
97+
98+
// ── Cached singleton resolution ─────────────────────────────────────────────
99+
const cachedC = buildFlat(n).build() as any;
100+
void cachedC.svc0; // prime + warm up
101+
void cachedC.svc0;
102+
const cachedTimes = measure(() => {
103+
void cachedC.svc0;
104+
}, RESOLVE_ITERS);
105+
106+
// ── Transient resolution ────────────────────────────────────────────────────
107+
const transientC = buildTransient(n).build() as any;
108+
void transientC.svc0; // warm up
109+
const transientTimes = measure(() => {
110+
void transientC.svc0;
111+
}, TRANSIENT_ITERS);
112+
113+
// ── Scope creation ──────────────────────────────────────────────────────────
114+
const scopeBase = buildFlat(n).build();
115+
void scopeBase.svc0; // ensure parent resolver is initialized
116+
const scopeOverride = { scopedVal: () => ({ v: 1 }) } as any;
117+
scopeBase.scope(scopeOverride); // warm up
118+
const scopeTimes = measure(() => {
119+
scopeBase.scope(scopeOverride);
120+
}, SCOPE_ITERS);
121+
122+
// ── Preload ─────────────────────────────────────────────────────────────────
123+
{
124+
const c = buildFlat(n).build();
125+
await c.preload(); // warm up
126+
}
127+
const preloadTimes: number[] = [];
128+
for (let i = 0; i < PRELOAD_ITERS; i++) {
129+
const c = buildFlat(n).build();
130+
const start = performance.now();
131+
await c.preload();
132+
preloadTimes.push((performance.now() - start) * 1_000_000);
133+
}
134+
135+
return {
136+
size: n,
137+
'build (median)': fmt(median(buildTimes)),
138+
'cold resolve (median)': fmt(median(coldTimes)),
139+
'cached resolve (median)': fmt(median(cachedTimes)),
140+
'transient resolve (median)': fmt(median(transientTimes)),
141+
'scope create (median)': fmt(median(scopeTimes)),
142+
'preload (median)': fmt(median(preloadTimes)),
143+
};
144+
}
145+
146+
// ── Main ──────────────────────────────────────────────────────────────────────
147+
148+
async function main(): Promise<void> {
149+
console.log('\n inwire — Resolution Performance Benchmarks');
150+
console.log(' ==========================================\n');
151+
152+
const results: BenchRow[] = [];
153+
154+
for (const n of SIZES) {
155+
process.stdout.write(` Running size=${n}...`);
156+
const row = await runForSize(n);
157+
results.push(row);
158+
process.stdout.write(' done\n');
159+
}
160+
161+
console.log('');
162+
console.table(results);
163+
164+
console.log('\n Notes:');
165+
console.log(' - All durations are median over multiple iterations (warmed up).');
166+
console.log(' - "cold resolve" rebuilds the container each time — includes build overhead.');
167+
console.log(' - "cached resolve" accesses an already-resolved singleton (pure Proxy overhead).');
168+
console.log(' - "transient resolve" always creates a new instance (no cache).');
169+
console.log(' - "preload" resolves all N providers eagerly in topological order.\n');
170+
}
171+
172+
main().catch((err) => {
173+
console.error(err);
174+
process.exit(1);
175+
});

examples/07-deno.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Deno usage: deno run --allow-env examples/07-deno.ts
2+
// In Deno, replace the import below with: import { container, transient } from 'npm:inwire@^3';
3+
/**
4+
* Example 07 — Deno runtime
5+
*
6+
* Showcases: container(), .add(), transient(), .build(), OnInit/OnDestroy lifecycle.
7+
* Run locally in Node/Bun for type-checking; swap the specifier to `npm:inwire@^3` for Deno.
8+
*/
9+
import { container, transient } from '../src/index.js';
10+
11+
// ── Services ────────────────────────────────────────────────────────────────
12+
13+
interface ICache {
14+
get(key: string): string | undefined;
15+
set(key: string, value: string): void;
16+
}
17+
18+
class InMemoryCache implements ICache {
19+
private store = new Map<string, string>();
20+
21+
onInit() {
22+
console.log('[Cache] ready');
23+
}
24+
25+
onDestroy() {
26+
this.store.clear();
27+
console.log('[Cache] cleared');
28+
}
29+
30+
get(key: string) {
31+
return this.store.get(key);
32+
}
33+
34+
set(key: string, value: string) {
35+
this.store.set(key, value);
36+
}
37+
}
38+
39+
// ── Container ────────────────────────────────────────────────────────────────
40+
41+
const app = container()
42+
.add('config', { env: 'deno', version: '1.0.0' })
43+
.add('cache', (): ICache => new InMemoryCache())
44+
// transient: a new request context is created on each access
45+
.addTransient(
46+
'requestId',
47+
transient(() => crypto.randomUUID()),
48+
)
49+
.build();
50+
51+
// ── Main ─────────────────────────────────────────────────────────────────────
52+
53+
async function main() {
54+
console.log(`env: ${app.config.env}`);
55+
56+
// OnInit triggered on first access
57+
app.cache.set('greeting', 'hello from Deno');
58+
console.log(`cache hit: ${app.cache.get('greeting')}`);
59+
60+
// transient: each access returns a fresh value
61+
const id1 = app.requestId;
62+
const id2 = app.requestId;
63+
console.log(`transient ids differ: ${id1 !== id2}`);
64+
65+
await app.dispose();
66+
}
67+
68+
main().catch(console.error);

0 commit comments

Comments
 (0)