Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.1] - 2026-10-24

### Fixed

- **An optional dependency missing from the configuration is skipped.** `startSystem`, `haltSystem` and `reconcileSystem` threw `depends on "X" which doesn't exist in the system config` for it, as for a required one. It is now no edge: the dependent starts and receives `undefined` for it, the topology (and its Mermaid and DOT output) draws nothing to it, and a missing required dependency still throws.
- **`reconcileSystem` halts a resource before restarting it on an added dependency.** Adding a resource that an unchanged one optionally depends on restarted the dependent without halting its previous instance.

## [0.3.0] - 2026-10-22

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("API Tests", () => {
Braided supports graceful degradation **when you opt in** by marking dependencies as optional.

- **Required dependencies (default)**: if a required dependency is unavailable, the dependent resource **will not start** and an error is recorded.
- **Optional dependencies**: if an optional dependency is unavailable, the dependent resource still starts and receives `undefined` for that dependency.
- **Optional dependencies**: if an optional dependency is unavailable, the dependent resource still starts and receives `undefined` for that dependency. An optional dependency may also be absent from the config altogether: it draws no edge, and `reconcileSystem` restarts its dependents when it is added or removed. A missing required dependency throws.

```typescript
const resilientApi = defineResource({
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "braided",
"version": "0.3.0",
"version": "0.3.1",
"type": "module",
"description": "Untangle your stateful resources. A minimal, type-safe library for declarative system composition with dependency-aware lifecycle management. Inspired by Clojure's Integrant.",
"main": "./dist/core.cjs",
Expand Down
13 changes: 13 additions & 0 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ export function normalizeDependencies(

return { required, optional, all };
}

/**
* The dependencies that are edges in a system of `resources`: every required
* one, and each optional one the system has. An optional dependency the
* system lacks is no edge; its dependent starts with `undefined` for it.
*/
export function dependencyEdges(
deps: DependenciesSpec | undefined,
resources: Record<string, unknown>
): readonly string[] {
const { required, all } = normalizeDependencies(deps);
return all.filter((id) => required.includes(id) || Object.hasOwn(resources, id));
}
19 changes: 13 additions & 6 deletions src/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
SystemConfig,
SystemReconcileResult,
} from "./resource";
import { normalizeDependencies } from "./dependencies";
import { dependencyEdges } from "./dependencies";
import { topologicalSort } from "./topological-sort";
import { buildTopology } from "./topology";
import { publicView, startInOrder, UNAVAILABLE } from "./system";
Expand All @@ -26,9 +26,12 @@ import { publicView, startInOrder, UNAVAILABLE } from "./system";
* configuration's `halt`)
* - changed and added resources are started, with every resource that depends
* on them, in dependency order, receiving the untouched instances and the
* ones just started
* ones just started; one of those that was running is halted first
* - every other resource keeps its instance, untouched
*
* An optional dependency absent from a configuration is no edge in it. Adding
* it restarts its dependents on it; removing it restarts them without it.
*
* A dependent is always restarted with what it depends on, so no instance
* holds a reference to a halted one.
*
Expand All @@ -43,7 +46,7 @@ import { publicView, startInOrder, UNAVAILABLE } from "./system";
* @param started - The started system to move from
* @param nextConfig - The configuration to move to
* @returns Promise resolving to the next started system, its errors and what moved
* @throws Error if the next configuration has a circular or missing dependency,
* @throws Error if the next configuration has a circular or missing required dependency,
* before anything is halted
*/
export async function reconcileSystem<
Expand All @@ -64,14 +67,18 @@ export async function reconcileSystem<
);
const added = Object.keys(nextConfig).filter((id) => !(id in prevConfig));

const toHalt = withDependents(prev, moved);
const dropped = withDependents(prev, moved);
const toStart = new Set([
...[...toHalt].filter((id) => id in nextConfig),
...[...dropped].filter((id) => id in nextConfig),
...withDependents(
next,
[...moved, ...added].filter((id) => id in nextConfig)
),
]);
const toHalt = new Set([
...dropped,
...[...toStart].filter((id) => id in prevConfig),
]);

const previous = started as Record<string, unknown>;
const halted: string[] = [];
Expand Down Expand Up @@ -125,7 +132,7 @@ function withDependents(
): Set<string> {
const dependents: Record<string, string[]> = {};
for (const [id, resource] of Object.entries(resources)) {
for (const dep of normalizeDependencies(resource.dependencies).all) {
for (const dep of dependencyEdges(resource.dependencies, resources)) {
(dependents[dep] ??= []).push(id);
}
}
Expand Down
35 changes: 35 additions & 0 deletions src/test/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,18 @@ describe("Resource System - Dependency Ordering", () => {
);
});

test("throws for a missing required dependency beside a missing optional one", async () => {
const dependent = defineResource({
dependencies: { required: ["missing"], optional: ["absent"] },
start: () => ({ value: "dependent" }),
halt: () => {},
});

await expect(startSystem({ dependent })).rejects.toThrow(
'depends on "missing" which doesn\'t exist'
);
});

test("handles diamond dependency pattern", async () => {
const startOrder: Array<string> = [];

Expand Down Expand Up @@ -537,6 +549,29 @@ describe("Resource System - Required vs Optional Dependencies", () => {
await haltSystem(config, system);
});

test("resource with an optional dependency missing from the config starts with undefined for it", async () => {
let received: Record<string, unknown> | undefined;
const optionalDependent = defineResource({
dependencies: { required: ["counter"], optional: ["absent"] },
start: (deps: { counter: any; absent?: any }) => {
received = deps;
return { mode: deps.absent ? "normal" : "degraded" };
},
halt: () => {},
});

const config = { counter: counterResource, optionalDependent };
const { system, errors, topology } = await startSystem(config);

expect(errors.size).toBe(0);
expect(system.optionalDependent.mode).toBe("degraded");
expect(received).toEqual({ counter: system.counter, absent: undefined });
expect(topology.graph["optionalDependent"]).toEqual(["counter"]);

const { errors: haltErrors } = await haltSystem(config, system);
expect(haltErrors.size).toBe(0);
});

test("resource with mixed required and optional dependencies", async () => {
const mixedDependent = defineResource({
dependencies: {
Expand Down
37 changes: 37 additions & 0 deletions src/test/reconcile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,43 @@ describe("reconcileSystem - adding and removing resources", () => {
expect(result.system.b).toBe(system.b);
});

test("a removed optional dependency restarts its dependent without it", async () => {
const t = tracked();
const config = {
a: t.resource("a"),
b: t.resource("b", { required: ["a"], optional: ["c"] }),
c: t.resource("c"),
};
const { system } = await startSystem(config);
t.log.length = 0;

const { c, ...next } = config;
const result = await reconcileSystem(config, system, next);

expect(t.log).toEqual(["halt b", "halt c", "start b"]);
expect(result.errors.size).toBe(0);
expect(result.system.b.deps).toEqual({ a: system.a, c: undefined });
expect(result.topology.graph["b"]).toEqual(["a"]);
});

test("an added optional dependency restarts its dependent on it", async () => {
const t = tracked();
const full = {
a: t.resource("a"),
b: t.resource("b", { required: ["a"], optional: ["c"] }),
c: t.resource("c"),
};
const { c, ...config } = full;
const { system } = await startSystem(config);
t.log.length = 0;

const result = await reconcileSystem(config, system, full);

expect(t.log).toEqual(["halt b", "start c", "start b"]);
expect(result.system.b.deps["c"]).toBe(result.system.c);
expect(result.system.a).toBe(system.a);
});

test("a removed resource is halted with its dependents", async () => {
const t = chain();
const { system } = await startSystem(t.config);
Expand Down
41 changes: 41 additions & 0 deletions src/test/topology.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,4 +761,45 @@ describe("System Topology", () => {
expect(parsed.totalResources).toBe(2);
});
});

describe("optional dependencies missing from the config", () => {
const resources = (): Record<string, Resource> => ({
config: { start: () => ({}), halt: () => {}, _status: "stopped" },
api: {
dependencies: { required: ["config"], optional: ["cache"] },
start: () => ({}),
halt: () => {},
_status: "stopped",
},
});

test("topologicalSort orders around a missing optional dependency", () => {
expect(topologicalSort(resources())).toEqual(["config", "api"]);
});

test("topologicalSort still throws for a missing required dependency", () => {
const withMissingRequired = {
...resources(),
worker: {
dependencies: { required: ["queue"], optional: ["cache"] },
start: () => ({}),
halt: () => {},
_status: "stopped",
} as Resource,
};
expect(() => topologicalSort(withMissingRequired)).toThrow(
'Resource "worker" depends on "queue" which doesn\'t exist'
);
});

test("a missing optional dependency draws no edge", () => {
const topology = buildTopology(resources(), topologicalSort(resources()));

expect(topology.graph).toEqual({ config: [], api: ["config"] });
expect(topology.dependents).toEqual({ config: ["api"], api: [] });
expect(topology.depths).toEqual({ config: 0, api: 1 });
expect(toMermaid(topology)).not.toContain("cache");
expect(toDot(topology)).not.toContain("cache");
});
});
});
5 changes: 2 additions & 3 deletions src/topological-sort.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Resource } from "./resource";
import { normalizeDependencies } from "./dependencies";
import { dependencyEdges } from "./dependencies";

/**
* Performs topological sort on resources using Kahn's algorithm.
Expand Down Expand Up @@ -28,8 +28,7 @@ export function topologicalSort(
// Build dependency graph
// If resource A depends on B, then B -> A in the graph
for (const [id, resource] of Object.entries(resources)) {
const { all } = normalizeDependencies(resource.dependencies);
for (const depId of all) {
for (const depId of dependencyEdges(resource.dependencies, resources)) {
if (!graph[depId]) {
throw new Error(
`Resource "${id}" depends on "${depId}" which doesn't exist in the system config`
Expand Down
4 changes: 2 additions & 2 deletions src/topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { Resource } from "./resource";
import { normalizeDependencies } from "./dependencies";
import { dependencyEdges } from "./dependencies";

/**
* A resource node in the topology graph
Expand Down Expand Up @@ -93,7 +93,7 @@ export function buildTopology(
// Build forward graph (resource -> dependencies)
const graph: Record<string, string[]> = {};
for (const id of Object.keys(resources)) {
graph[id] = [...normalizeDependencies(resources[id].dependencies).all];
graph[id] = [...dependencyEdges(resources[id].dependencies, resources)];
}

// Build reverse graph (resource -> dependents)
Expand Down
Loading