@pumped-fn/lite 1.11.4 → 2.1.0

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.mjs","names":["categories: Record<string, { title: string; content: string | (() => string) }>"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { readFileSync } from \"node:fs\"\nimport { dirname, join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\n\nconst pkgDir = join(dirname(fileURLToPath(import.meta.url)), \"..\")\nconst readme = readFileSync(join(pkgDir, \"README.md\"), \"utf-8\")\n\nfunction extractOverview(): string {\n const idx = readme.indexOf(\"## How It Works\")\n const raw = idx === -1 ? readme : readme.slice(0, idx)\n return raw.replace(/^#[^\\n]*\\n+/, \"\").trim()\n}\n\nfunction extractDiagram(): string {\n const match = readme.match(/```mermaid\\n([\\s\\S]*?)```/)\n return match ? `Full system sequence (unified):\\n\\n\\`\\`\\`mermaid\\n${match[1]!.trim()}\\n\\`\\`\\`` : \"No diagram found in README.md\"\n}\n\nconst categories: Record<string, { title: string; content: string | (() => string) }> = {\n overview: {\n title: \"What is @pumped-fn/lite\",\n content: extractOverview,\n },\n\n primitives: {\n title: \"Primitives API\",\n content: `atom({ factory, deps?, tags?, keepAlive? })\n Creates a managed effect. Factory receives (ctx, resolvedDeps) and returns a value.\n Cached per scope. Supports cleanup via ctx.onClose().\n\n import { atom } from \"@pumped-fn/lite\"\n const dbAtom = atom({ factory: () => createDbPool() })\n const userAtom = atom({\n deps: { db: dbAtom },\n factory: (ctx, { db }) => db.query(\"SELECT ...\"),\n })\n\nflow({ factory, parse?, deps?, tags? })\n Operation template executed per call. parse validates input, factory runs logic.\n\n import { flow, typed } from \"@pumped-fn/lite\"\n const getUser = flow({\n parse: typed<{ id: string }>(),\n deps: { db: dbAtom },\n factory: (ctx, { db }) => db.findUser(ctx.input.id),\n })\n\ntag({ label, default?, parse? })\n Ambient context value. Attach to atoms/flows/contexts. Retrieve via tag.get/find/collect.\n\n import { tag } from \"@pumped-fn/lite\"\n const tenantTag = tag<string>({ label: \"tenant\" })\n\npreset(target, value)\n Override an atom's resolved value. Used for testing and multi-tenant isolation.\n\n import { preset } from \"@pumped-fn/lite\"\n const mockDb = preset(dbAtom, fakeDatabaseInstance)\n\nservice({ factory, deps? })\n Convenience wrapper for atom whose value is an object of methods.\n Each method receives (ctx, ...args) for tracing/auth integration.`,\n },\n\n scope: {\n title: \"Scope Management\",\n content: `createScope({ extensions?, presets?, tags?, gc? })\n Creates a scope that manages atom resolution, caching, extensions, and GC.\n\n import { createScope } from \"@pumped-fn/lite\"\n const scope = createScope({\n extensions: [loggingExt],\n presets: [preset(dbAtom, mockDb)],\n tags: [tenantTag(\"acme\")],\n gc: { enabled: true, graceMs: 3000 },\n })\n await scope.ready\n\nscope.resolve(atom) → Promise<value> resolve and cache an atom\nscope.controller(atom) → Controller get reactive handle\nscope.select(atom, fn, opts?) → SelectHandle derived slice with equality check\nscope.on(event, atom, fn) → unsubscribe listen to atom events\nscope.release(atom) → void release atom, run cleanups\nscope.createContext(opts?) → ExecutionContext create execution boundary\nscope.flush() → Promise<void> wait all pending operations\nscope.dispose() → void release everything, run all cleanups`,\n },\n\n context: {\n title: \"ExecutionContext\",\n content: `ctx = scope.createContext({ tags? })\n Execution boundary. Tags merge with scope tags. Cleanup runs LIFO on close.\n\nctx.exec({ flow, input?, tags? }) → Promise<output>\n Execute a flow within this context. Creates a child context with merged tags.\n Child context closes automatically after execution.\n\nctx.exec({ fn, params?, tags? }) → Promise<result>\n Execute an inline function: fn(childCtx, ...params).\n Same child-context lifecycle as flow execution.\n\nctx.onClose(cleanup) → void register cleanup (runs LIFO on ctx.close)\nctx.close() → void run all registered cleanups in LIFO order\n\nctx.data\n Key-value store scoped to the context:\n Raw: get(key) / set(key, val) / has(key) / delete(key) / clear() / seek(key)\n Typed: getTag(tag) / setTag(tag, val) / hasTag(tag) / deleteTag(tag) / seekTag(tag) / getOrSetTag(tag, factory)\n\n seek/seekTag walks up the context chain to find values in parent contexts.`,\n },\n\n reactivity: {\n title: \"Reactivity (opt-in)\",\n content: `controller(atom) → Controller\n Opt-in reactive handle for an atom.\n\n ctrl.get() → current value (must be resolved first)\n ctrl.resolve() → Promise<value> (resolve if not yet)\n ctrl.set(value) → replace value, notify listeners\n ctrl.update(fn) → update value via function, notify listeners\n ctrl.invalidate() → re-run factory, notify listeners\n ctrl.release() → release atom, run cleanups\n ctrl.on(event, listener) → unsubscribe\n events: 'resolving' | 'resolved' | '*'\n\nselect(atom, selector, { eq? }) → SelectHandle\n Derived state slice. Only notifies when selected value changes per eq function.\n\n handle.get() → current selected value\n handle.subscribe(fn) → unsubscribe\n\nscope.on('resolved', atom, listener) → unsubscribe\n Listen to atom resolution events at scope level.\n\nController as dependency:\n import { controller } from \"@pumped-fn/lite\"\n const serverAtom = atom({\n deps: { cfg: controller(configAtom, { resolve: true }) },\n factory: (ctx, { cfg }) => {\n cfg.on('resolved', () => ctx.invalidate())\n return createServer(cfg.get())\n },\n })`,\n },\n\n tags: {\n title: \"Tag System\",\n content: `tag<T>({ label, default?, parse? }) → Tag<T>\n Define an ambient context value type.\n\ntag(value) → Tagged<T>\n Create a tagged value to attach to atoms, flows, or contexts.\n\nAttaching tags:\n atom({ tags: [tenantTag(\"acme\")] })\n flow({ tags: [roleTag(\"admin\")] })\n scope.createContext({ tags: [userTag(currentUser)] })\n ctx.exec({ flow, tags: [localeTag(\"en\")] })\n\nReading tags:\n tag.get(source) → T first match or throw\n tag.find(source) → T | undefined first match or undefined\n tag.collect(source) → T[] all matches\n\nContext data integration:\n ctx.data.setTag(tag, value)\n ctx.data.getTag(tag) → T\n ctx.data.seekTag(tag) → T (walks parent chain)\n ctx.data.hasTag(tag) → boolean\n\nTag executor (dependency wiring):\n tags.required(tag) → resolves tag or throws\n tags.optional(tag) → resolves tag or undefined\n tags.all(tag) → resolves all values for tag\n\nIntrospection:\n tag.atoms() → Atom[] with this tag attached\n getAllTags() → Tag[] all registered tags`,\n },\n\n extensions: {\n title: \"Extensions Pipeline\",\n content: `Extensions wrap atom resolution and flow execution (middleware pattern).\n\ninterface Extension {\n init?(scope): void | Promise<void>\n dispose?(scope): void\n wrapResolve?(next, event: ResolveEvent): Promise<value>\n wrapExec?(next, flow, ctx): Promise<output>\n}\n\ncreateScope({ extensions: [ext1, ext2] })\n\nLifecycle:\n 1. scope creation → ext.init(scope) called for each extension\n 2. await scope.ready → all init() resolved\n 3. resolve(atom) → ext.wrapResolve(next, { kind: \"atom\", target, scope })\n resolve(resource) → ext.wrapResolve(next, { kind: \"resource\", target, ctx })\n - call next() to proceed to actual resolution\n - dispatch on event.kind for atom vs resource\n 4. ctx.exec(flow) → ext.wrapExec(next, flow, ctx)\n - call next() to proceed to actual execution\n 5. scope.dispose() → ext.dispose(scope) called for each extension\n\nExample:\n const timingExt: Extension = {\n wrapResolve: async (next, event) => {\n const start = Date.now()\n const value = await next()\n console.log(event.target, Date.now() - start, \"ms\")\n return value\n },\n }`,\n },\n\n testing: {\n title: \"Testing & Isolation\",\n content: `Use presets to swap implementations without changing production code.\n\nimport { createScope, preset } from \"@pumped-fn/lite\"\n\nconst scope = createScope({\n presets: [\n preset(dbAtom, mockDatabase),\n preset(cacheAtom, inMemoryCache),\n ],\n tags: [tenantTag(\"test-tenant\")],\n})\n\nconst db = await scope.resolve(dbAtom) // → mockDatabase (not real db)\n\nMulti-tenant isolation:\n Each scope is fully isolated. Create one scope per tenant/test.\n\n const tenantScope = createScope({\n tags: [tenantTag(tenantId)],\n presets: tenantOverrides,\n })\n\nCleanup:\n scope.dispose() releases all atoms and runs all cleanup functions.\n In tests: call scope.dispose() in afterEach.`,\n },\n\n patterns: {\n title: \"Common Patterns\",\n content: `Request lifecycle:\n const scope = createScope()\n const ctx = scope.createContext({ tags: [requestTag(req)] })\n const result = await ctx.exec({ flow: handleRequest, input: req.body })\n ctx.close() // cleanup LIFO\n\nService pattern:\n const userService = service({\n deps: { db: dbAtom },\n factory: (ctx, { db }) => ({\n getUser: (ctx, id) => db.findUser(id),\n updateUser: (ctx, id, data) => db.updateUser(id, data),\n }),\n })\n\nTyped flow input:\n const getUser = flow({\n parse: typed<{ id: string }>(),\n factory: (ctx) => findUser(ctx.input.id),\n })\n\nInline execution:\n const result = await ctx.exec({\n fn: (ctx, a, b) => a + b,\n params: [1, 2],\n })\n\nAtom with cleanup:\n const serverAtom = atom({\n factory: (ctx) => {\n const server = createServer()\n ctx.onClose(() => server.close())\n return server\n },\n })\n\nAtom retention / GC:\n createScope({ gc: { enabled: true, graceMs: 3000 } })\n atom({ keepAlive: true }) // never GC'd`,\n },\n\n diagrams: {\n title: \"Visual Diagrams (mermaid)\",\n content: extractDiagram,\n },\n\n types: {\n title: \"Type Utilities & Guards\",\n content: `Type extractors (Lite.Utils namespace):\n AtomValue<A> extract resolved type from atom\n FlowOutput<F> extract output type from flow\n FlowInput<F> extract input type from flow\n TagValue<T> extract value type from tag\n DepsOf<A | F> extract deps record type\n ControllerValue<C> extract value from controller\n Simplify<T> flatten intersection types\n AtomType<T, D> construct atom type\n FlowType<O, I, D> construct flow type\n\nType guards:\n isAtom(v) → v is Atom\n isFlow(v) → v is Flow\n isTag(v) → v is Tag\n isTagged(v) → v is Tagged\n isPreset(v) → v is Preset\n isControllerDep(v) → v is ControllerDep\n isTagExecutor(v) → v is TagExecutor\n\nConvenience types:\n AnyAtom any atom regardless of value/deps\n AnyFlow any flow regardless of output/input/deps\n AnyController any controller regardless of value\n\nSymbols (advanced, for library authors):\n atomSymbol, flowSymbol, tagSymbol, taggedSymbol,\n presetSymbol, controllerSymbol, controllerDepSymbol,\n tagExecutorSymbol, typedSymbol`,\n },\n}\n\nconst args = process.argv.slice(2)\nconst category = args[0]\n\nif (!category || category === \"help\" || category === \"--help\") {\n console.log(\"@pumped-fn/lite — Scoped Ambient State for TypeScript\\n\")\n console.log(\"Usage: pumped-lite <category>\\n\")\n console.log(\"Categories:\")\n for (const [key, { title }] of Object.entries(categories)) {\n console.log(` ${key.padEnd(14)} ${title}`)\n }\n console.log(\"\\nExamples:\")\n console.log(\" npx @pumped-fn/lite primitives # API reference\")\n console.log(\" npx @pumped-fn/lite diagrams # mermaid diagrams\")\n process.exit(0)\n}\n\nif (!(category in categories)) {\n console.error(`Unknown category: \"${category}\"\\n`)\n console.error(\"Available categories: \" + Object.keys(categories).join(\", \"))\n process.exit(1)\n}\n\nconst entry = categories[category]!\nconst output = typeof entry.content === \"function\" ? entry.content() : entry.content\nconsole.log(`# ${entry.title}\\n`)\nconsole.log(output)\n"],"mappings":";;;;;;AAOA,MAAM,SAAS,aAAa,KADb,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,KAAK,EACzB,YAAY,EAAE,QAAQ;AAE/D,SAAS,kBAA0B;CACjC,MAAM,MAAM,OAAO,QAAQ,kBAAkB;AAE7C,SADY,QAAQ,KAAK,SAAS,OAAO,MAAM,GAAG,IAAI,EAC3C,QAAQ,eAAe,GAAG,CAAC,MAAM;;AAG9C,SAAS,iBAAyB;CAChC,MAAM,QAAQ,OAAO,MAAM,4BAA4B;AACvD,QAAO,QAAQ,qDAAqD,MAAM,GAAI,MAAM,CAAC,YAAY;;AAGnG,MAAMA,aAAkF;CACtF,UAAU;EACR,OAAO;EACP,SAAS;EACV;CAED,YAAY;EACV,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoCV;CAED,OAAO;EACL,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;EAoBV;CAED,SAAS;EACP,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;EAoBV;CAED,YAAY;EACV,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8BV;CAED,MAAM;EACJ,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+BV;CAED,YAAY;EACV,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+BV;CAED,SAAS;EACP,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;EAyBV;CAED,UAAU;EACR,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuCV;CAED,UAAU;EACR,OAAO;EACP,SAAS;EACV;CAED,OAAO;EACL,OAAO;EACP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6BV;CACF;AAGD,MAAM,WADO,QAAQ,KAAK,MAAM,EAAE,CACZ;AAEtB,IAAI,CAAC,YAAY,aAAa,UAAU,aAAa,UAAU;AAC7D,SAAQ,IAAI,0DAA0D;AACtE,SAAQ,IAAI,kCAAkC;AAC9C,SAAQ,IAAI,cAAc;AAC1B,MAAK,MAAM,CAAC,KAAK,EAAE,YAAY,OAAO,QAAQ,WAAW,CACvD,SAAQ,IAAI,KAAK,IAAI,OAAO,GAAG,CAAC,GAAG,QAAQ;AAE7C,SAAQ,IAAI,cAAc;AAC1B,SAAQ,IAAI,qDAAqD;AACjE,SAAQ,IAAI,wDAAwD;AACpE,SAAQ,KAAK,EAAE;;AAGjB,IAAI,EAAE,YAAY,aAAa;AAC7B,SAAQ,MAAM,sBAAsB,SAAS,KAAK;AAClD,SAAQ,MAAM,2BAA2B,OAAO,KAAK,WAAW,CAAC,KAAK,KAAK,CAAC;AAC5E,SAAQ,KAAK,EAAE;;AAGjB,MAAM,QAAQ,WAAW;AACzB,MAAM,SAAS,OAAO,MAAM,YAAY,aAAa,MAAM,SAAS,GAAG,MAAM;AAC7E,QAAQ,IAAI,KAAK,MAAM,MAAM,IAAI;AACjC,QAAQ,IAAI,OAAO"}
package/dist/index.cjs CHANGED
@@ -9,6 +9,7 @@ const presetSymbol = Symbol.for("@pumped-fn/lite/preset");
9
9
  const controllerSymbol = Symbol.for("@pumped-fn/lite/controller");
10
10
  const tagExecutorSymbol = Symbol.for("@pumped-fn/lite/tag-executor");
11
11
  const typedSymbol = Symbol.for("@pumped-fn/lite/typed");
12
+ const resourceSymbol = Symbol.for("@pumped-fn/lite/resource");
12
13
 
13
14
  //#endregion
14
15
  //#region src/errors.ts
@@ -262,20 +263,33 @@ function isAtom(value) {
262
263
  * The Controller provides full lifecycle control: get, resolve, release, invalidate, and subscribe.
263
264
  *
264
265
  * @param atom - The Atom to wrap
265
- * @param options - Optional configuration. Use { resolve: true } to auto-resolve before factory runs.
266
+ * @param options - Optional configuration:
267
+ * - `resolve: true` — auto-resolves the dep before the parent factory runs; `config.get()` is safe.
268
+ * - `watch: true` — atom deps only; requires `resolve: true`; automatically re-runs the parent factory
269
+ * when the dep resolves to a new value (value-equality gated via `Object.is` by default). Replaces
270
+ * manual `ctx.cleanup(ctx.scope.on('resolved', dep, () => ctx.invalidate()))` wiring. Watch
271
+ * listeners are auto-cleaned on re-resolve, release, and dispose.
272
+ * - `eq` — custom equality function `(a: T, b: T) => boolean`; only used with `watch: true`.
266
273
  * @returns A ControllerDep that resolves to a Controller for the Atom
267
274
  *
268
275
  * @example
269
276
  * ```typescript
270
- * const configAtom = atom({ factory: () => fetchConfig() })
277
+ * // resolve only
271
278
  * const serverAtom = atom({
272
279
  * deps: { config: controller(configAtom, { resolve: true }) },
273
- * factory: (ctx, { config }) => {
274
- * // config.get() is safe - already resolved
275
- * const unsub = config.on('resolved', () => ctx.invalidate())
276
- * ctx.cleanup(unsub)
277
- * return createServer(config.get().port)
278
- * }
280
+ * factory: (_, { config }) => createServer(config.get().port),
281
+ * })
282
+ *
283
+ * // watch: re-runs parent when dep value changes
284
+ * const profileAtom = atom({
285
+ * deps: { token: controller(tokenAtom, { resolve: true, watch: true }) },
286
+ * factory: (_, { token }) => ({ id: `user-${token.get().jwt}` }),
287
+ * })
288
+ *
289
+ * // watch with custom equality
290
+ * const derivedAtom = atom({
291
+ * deps: { src: controller(srcAtom, { resolve: true, watch: true, eq: (a, b) => a.id === b.id }) },
292
+ * factory: (_, { src }) => src.get().name,
279
293
  * })
280
294
  * ```
281
295
  */
@@ -283,7 +297,9 @@ function controller(atom$1, options) {
283
297
  return {
284
298
  [controllerDepSymbol]: true,
285
299
  atom: atom$1,
286
- resolve: options?.resolve
300
+ resolve: options?.resolve,
301
+ watch: options?.watch,
302
+ eq: options?.eq
287
303
  };
288
304
  }
289
305
  /**
@@ -379,6 +395,33 @@ function isPreset(value) {
379
395
  return typeof value === "object" && value !== null && value[presetSymbol] === true;
380
396
  }
381
397
 
398
+ //#endregion
399
+ //#region src/resource.ts
400
+ function resource(config) {
401
+ return Object.freeze({
402
+ [resourceSymbol]: true,
403
+ name: config.name,
404
+ deps: config.deps,
405
+ factory: config.factory
406
+ });
407
+ }
408
+ /**
409
+ * Type guard to check if a value is a Resource.
410
+ *
411
+ * @param value - The value to check
412
+ * @returns True if the value is a Resource, false otherwise
413
+ *
414
+ * @example
415
+ * ```typescript
416
+ * if (isResource(value)) {
417
+ * // value is Lite.Resource<unknown>
418
+ * }
419
+ * ```
420
+ */
421
+ function isResource(value) {
422
+ return typeof value === "object" && value !== null && value[resourceSymbol] === true;
423
+ }
424
+
382
425
  //#endregion
383
426
  //#region src/service.ts
384
427
  function service(config) {
@@ -392,6 +435,18 @@ function service(config) {
392
435
 
393
436
  //#endregion
394
437
  //#region src/scope.ts
438
+ const resourceKeys = /* @__PURE__ */ new WeakMap();
439
+ let resourceKeyCounter = 0;
440
+ function getResourceKey(resource$1) {
441
+ let key = resourceKeys.get(resource$1);
442
+ if (!key) {
443
+ key = Symbol(`resource:${resource$1.name ?? resourceKeyCounter++}`);
444
+ resourceKeys.set(resource$1, key);
445
+ }
446
+ return key;
447
+ }
448
+ const inflightResources = /* @__PURE__ */ new WeakMap();
449
+ const resolvingResources = /* @__PURE__ */ new Set();
395
450
  var ContextDataImpl = class {
396
451
  map = /* @__PURE__ */ new Map();
397
452
  constructor(parentData) {
@@ -731,6 +786,8 @@ var ScopeImpl = class {
731
786
  async doResolve(atom$1) {
732
787
  const entry = this.getOrCreateEntry(atom$1);
733
788
  if (!(entry.state === "resolving")) {
789
+ for (let i = entry.cleanups.length - 1; i >= 0; i--) await entry.cleanups[i]?.();
790
+ entry.cleanups = [];
734
791
  entry.state = "resolving";
735
792
  this.emitStateChange("resolving", atom$1);
736
793
  this.notifyListeners(atom$1, "resolving");
@@ -753,7 +810,12 @@ var ScopeImpl = class {
753
810
  else return factory(ctx);
754
811
  };
755
812
  try {
756
- const value = await this.applyResolveExtensions(atom$1, doResolve);
813
+ const event = {
814
+ kind: "atom",
815
+ target: atom$1,
816
+ scope: this
817
+ };
818
+ const value = await this.applyResolveExtensions(event, doResolve);
757
819
  entry.state = "resolved";
758
820
  entry.value = value;
759
821
  entry.hasValue = true;
@@ -784,13 +846,13 @@ var ScopeImpl = class {
784
846
  throw entry.error;
785
847
  }
786
848
  }
787
- async applyResolveExtensions(atom$1, doResolve) {
849
+ async applyResolveExtensions(event, doResolve) {
788
850
  let next = doResolve;
789
851
  for (let i = this.extensions.length - 1; i >= 0; i--) {
790
852
  const ext = this.extensions[i];
791
853
  if (ext?.wrapResolve) {
792
854
  const currentNext = next;
793
- next = ext.wrapResolve.bind(ext, currentNext, atom$1, this);
855
+ next = ext.wrapResolve.bind(ext, currentNext, event);
794
856
  }
795
857
  }
796
858
  return next();
@@ -805,13 +867,31 @@ var ScopeImpl = class {
805
867
  if (depEntry) depEntry.dependents.add(dependentAtom);
806
868
  }
807
869
  } else if (isControllerDep(dep)) {
808
- const ctrl = new ControllerImpl(dep.atom, this);
870
+ if (dep.watch) {
871
+ if (!dependentAtom) throw new Error("controller({ watch: true }) is only supported in atom dependencies");
872
+ if (!dep.resolve) throw new Error("controller({ watch: true }) requires resolve: true");
873
+ }
874
+ const ctrl = this.controller(dep.atom);
809
875
  if (dep.resolve) await ctrl.resolve();
810
876
  result[key] = ctrl;
811
877
  if (dependentAtom) {
812
878
  const depEntry = this.getEntry(dep.atom);
813
879
  if (depEntry) depEntry.dependents.add(dependentAtom);
814
880
  }
881
+ if (dep.watch) {
882
+ const eq = dep.eq ?? Object.is;
883
+ let prev = ctrl.get();
884
+ const unsub = this.on("resolved", dep.atom, () => {
885
+ const next = ctrl.get();
886
+ if (!eq(prev, next)) {
887
+ prev = next;
888
+ this.scheduleInvalidation(dependentAtom);
889
+ }
890
+ });
891
+ const depEntry = this.getEntry(dependentAtom);
892
+ if (depEntry) depEntry.cleanups.push(unsub);
893
+ else unsub();
894
+ }
815
895
  } else if (tagExecutorSymbol in dep) {
816
896
  const tagExecutor = dep;
817
897
  switch (tagExecutor.mode) {
@@ -829,6 +909,59 @@ var ScopeImpl = class {
829
909
  result[key] = ctx ? this.collectFromHierarchy(ctx, tagExecutor.tag) : tagExecutor.tag.collect(this.tags);
830
910
  break;
831
911
  }
912
+ } else if (isResource(dep)) {
913
+ if (!ctx) throw new Error("Resource deps require an ExecutionContext");
914
+ const resource$1 = dep;
915
+ const resourceKey = getResourceKey(resource$1);
916
+ const storeCtx = ctx.parent ?? ctx;
917
+ if (storeCtx.data.has(resourceKey)) {
918
+ result[key] = storeCtx.data.get(resourceKey);
919
+ continue;
920
+ }
921
+ const existingSeek = ctx.data.seek(resourceKey);
922
+ if (existingSeek !== void 0 || ctx.data.has(resourceKey)) {
923
+ result[key] = existingSeek;
924
+ continue;
925
+ }
926
+ if (resolvingResources.has(resourceKey)) throw new Error(`Circular resource dependency detected: ${resource$1.name ?? "anonymous"}`);
927
+ let flights = inflightResources.get(storeCtx.data);
928
+ if (!flights) {
929
+ flights = /* @__PURE__ */ new Map();
930
+ inflightResources.set(storeCtx.data, flights);
931
+ }
932
+ const inflight = flights.get(resourceKey);
933
+ if (inflight) {
934
+ result[key] = await inflight;
935
+ continue;
936
+ }
937
+ const resolve = async () => {
938
+ resolvingResources.add(resourceKey);
939
+ try {
940
+ const resourceDeps = await this.resolveDeps(resource$1.deps, ctx);
941
+ const event = {
942
+ kind: "resource",
943
+ target: resource$1,
944
+ ctx: storeCtx
945
+ };
946
+ const doResolve = async () => {
947
+ const factory = resource$1.factory;
948
+ if (resource$1.deps && Object.keys(resource$1.deps).length > 0) return factory(storeCtx, resourceDeps);
949
+ return factory(storeCtx);
950
+ };
951
+ const value = await this.applyResolveExtensions(event, doResolve);
952
+ storeCtx.data.set(resourceKey, value);
953
+ return value;
954
+ } finally {
955
+ resolvingResources.delete(resourceKey);
956
+ }
957
+ };
958
+ const promise = resolve();
959
+ flights.set(resourceKey, promise);
960
+ try {
961
+ result[key] = await promise;
962
+ } finally {
963
+ flights.delete(resourceKey);
964
+ }
832
965
  }
833
966
  return result;
834
967
  }
@@ -1006,10 +1139,15 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1006
1139
  for (const tagged of execTags ?? []) childCtx.data.set(tagged.key, tagged.value);
1007
1140
  for (const tagged of flow$1.tags ?? []) if (!childCtx.data.has(tagged.key)) childCtx.data.set(tagged.key, tagged.value);
1008
1141
  try {
1009
- if (presetValue !== void 0 && typeof presetValue === "function") return await childCtx.execPresetFn(flow$1, presetValue);
1010
- return await childCtx.execFlowInternal(flow$1);
1011
- } finally {
1012
- await childCtx.close();
1142
+ const result = presetValue !== void 0 && typeof presetValue === "function" ? await childCtx.execPresetFn(flow$1, presetValue) : await childCtx.execFlowInternal(flow$1);
1143
+ await childCtx.close({ ok: true });
1144
+ return result;
1145
+ } catch (error) {
1146
+ await childCtx.close({
1147
+ ok: false,
1148
+ error
1149
+ });
1150
+ throw error;
1013
1151
  }
1014
1152
  } else {
1015
1153
  const childCtx = new ExecutionContextImpl(this.scope, {
@@ -1019,9 +1157,15 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1019
1157
  input: options.params
1020
1158
  });
1021
1159
  try {
1022
- return await childCtx.execFnInternal(options);
1023
- } finally {
1024
- await childCtx.close();
1160
+ const result = await childCtx.execFnInternal(options);
1161
+ await childCtx.close({ ok: true });
1162
+ return result;
1163
+ } catch (error) {
1164
+ await childCtx.close({
1165
+ ok: false,
1166
+ error
1167
+ });
1168
+ throw error;
1025
1169
  }
1026
1170
  }
1027
1171
  }
@@ -1057,12 +1201,12 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1057
1201
  onClose(fn) {
1058
1202
  this.cleanups.push(fn);
1059
1203
  }
1060
- async close() {
1204
+ async close(result = { ok: true }) {
1061
1205
  if (this.closed) return;
1062
1206
  this.closed = true;
1063
1207
  for (let i = this.cleanups.length - 1; i >= 0; i--) {
1064
1208
  const cleanup = this.cleanups[i];
1065
- if (cleanup) await cleanup();
1209
+ if (cleanup) await cleanup(result);
1066
1210
  }
1067
1211
  }
1068
1212
  };
@@ -1115,11 +1259,14 @@ exports.isAtom = isAtom;
1115
1259
  exports.isControllerDep = isControllerDep;
1116
1260
  exports.isFlow = isFlow;
1117
1261
  exports.isPreset = isPreset;
1262
+ exports.isResource = isResource;
1118
1263
  exports.isTag = isTag;
1119
1264
  exports.isTagExecutor = isTagExecutor;
1120
1265
  exports.isTagged = isTagged;
1121
1266
  exports.preset = preset;
1122
1267
  exports.presetSymbol = presetSymbol;
1268
+ exports.resource = resource;
1269
+ exports.resourceSymbol = resourceSymbol;
1123
1270
  exports.service = service;
1124
1271
  exports.tag = tag;
1125
1272
  exports.tagExecutorSymbol = tagExecutorSymbol;
package/dist/index.d.cts CHANGED
@@ -8,6 +8,7 @@ declare const presetSymbol: unique symbol;
8
8
  declare const controllerSymbol: unique symbol;
9
9
  declare const tagExecutorSymbol: unique symbol;
10
10
  declare const typedSymbol: unique symbol;
11
+ declare const resourceSymbol: unique symbol;
11
12
  //#endregion
12
13
  //#region src/types.d.ts
13
14
  type MaybePromise<T> = T | Promise<T>;
@@ -58,6 +59,12 @@ declare namespace Lite {
58
59
  readonly deps?: Record<string, Dependency>;
59
60
  readonly tags?: Tagged<any>[];
60
61
  }
62
+ interface Resource<T, D extends Record<string, Dependency> = Record<string, Dependency>> {
63
+ readonly [resourceSymbol]: true;
64
+ readonly name?: string;
65
+ readonly deps?: D;
66
+ readonly factory: ResourceFactory<T, D>;
67
+ }
61
68
  /**
62
69
  * Unified context data storage with both raw Map operations and Tag-based DX.
63
70
  */
@@ -112,6 +119,12 @@ declare namespace Lite {
112
119
  readonly scope: Scope;
113
120
  readonly data: ContextData;
114
121
  }
122
+ type CloseResult = {
123
+ ok: true;
124
+ } | {
125
+ ok: false;
126
+ error: unknown;
127
+ };
115
128
  interface ExecutionContext {
116
129
  readonly input: unknown;
117
130
  readonly name: string | undefined;
@@ -120,8 +133,8 @@ declare namespace Lite {
120
133
  readonly data: ContextData;
121
134
  exec<Output, Input>(options: ExecFlowOptions<Output, Input>): Promise<Output>;
122
135
  exec<Output, Args extends unknown[]>(options: ExecFnOptions<Output, Args>): Promise<Output>;
123
- onClose(fn: () => MaybePromise<void>): void;
124
- close(): Promise<void>;
136
+ onClose(fn: (result: CloseResult) => MaybePromise<void>): void;
137
+ close(result?: CloseResult): Promise<void>;
125
138
  }
126
139
  type ExecFlowOptions<Output, Input> = {
127
140
  flow: Flow<Output, Input>;
@@ -217,10 +230,17 @@ declare namespace Lite {
217
230
  readonly [controllerDepSymbol]: true;
218
231
  readonly atom: Atom<T>;
219
232
  readonly resolve?: boolean;
233
+ readonly watch?: boolean;
234
+ readonly eq?: (a: any, b: any) => boolean;
220
235
  }
221
236
  interface ControllerOptions {
222
237
  resolve?: boolean;
223
238
  }
239
+ interface ControllerDepOptions<T> {
240
+ resolve?: boolean;
241
+ watch?: boolean;
242
+ eq?: (a: T, b: T) => boolean;
243
+ }
224
244
  interface Typed<T> {
225
245
  readonly [typedSymbol]: true;
226
246
  }
@@ -233,15 +253,38 @@ declare namespace Lite {
233
253
  readonly target: PresetTarget<T, I>;
234
254
  readonly value: PresetValue<T, I>;
235
255
  }
256
+ /**
257
+ * Discriminated context for `wrapResolve`.
258
+ *
259
+ * - `"atom"` — scope-level singleton. Cached after first resolve.
260
+ * - `"resource"` — execution-level. Fresh factory per first encounter,
261
+ * seek-up on nested execs within the same chain.
262
+ */
263
+ type ResolveEvent = {
264
+ readonly kind: "atom";
265
+ readonly target: Atom<unknown>;
266
+ readonly scope: Scope;
267
+ } | {
268
+ readonly kind: "resource";
269
+ readonly target: Resource<unknown>;
270
+ readonly ctx: ExecutionContext;
271
+ };
236
272
  interface Extension {
237
273
  readonly name: string;
238
274
  init?(scope: Scope): MaybePromise<void>;
239
- wrapResolve?(next: () => Promise<unknown>, atom: Atom<unknown>, scope: Scope): Promise<unknown>;
275
+ /**
276
+ * Wraps dependency resolution. Dispatch by `event.kind`:
277
+ *
278
+ * - `"atom"` — `event.scope`, `event.target: Atom`. Cached in scope.
279
+ * - `"resource"` — `event.ctx`, `event.target: Resource`. Seek-up in
280
+ * execution hierarchy, factory(ctx, deps) on miss.
281
+ */
282
+ wrapResolve?(next: () => Promise<unknown>, event: ResolveEvent): Promise<unknown>;
240
283
  wrapExec?(next: () => Promise<unknown>, target: ExecTarget, ctx: ExecutionContext): Promise<unknown>;
241
284
  dispose?(scope: Scope): MaybePromise<void>;
242
285
  }
243
- type Dependency = Atom<unknown> | ControllerDep<unknown> | TagExecutor<any>;
244
- type InferDep<D> = D extends Atom<infer T> ? T : D extends ControllerDep<infer T> ? Controller<T> : D extends TagExecutor<infer TOutput, infer _TTag> ? TOutput : never;
286
+ type Dependency = Atom<unknown> | ControllerDep<unknown> | TagExecutor<any> | Resource<unknown>;
287
+ type InferDep<D> = D extends Atom<infer T> ? T : D extends ControllerDep<infer T> ? Controller<T> : D extends TagExecutor<infer TOutput, infer _TTag> ? TOutput : D extends Resource<infer T> ? T : never;
245
288
  type InferDeps<D> = { [K in keyof D]: InferDep<D[K]> };
246
289
  type AtomFactory<T, D extends Record<string, Dependency>> = keyof D extends never ? (ctx: ResolveContext) => MaybePromise<T> : (ctx: ResolveContext, deps: InferDeps<D>) => MaybePromise<T>;
247
290
  type FlowFactory<Output, Input, D extends Record<string, Dependency>> = keyof D extends never ? (ctx: ExecutionContext & {
@@ -249,6 +292,7 @@ declare namespace Lite {
249
292
  }) => MaybePromise<Output> : (ctx: ExecutionContext & {
250
293
  readonly input: Input;
251
294
  }, deps: InferDeps<D>) => MaybePromise<Output>;
295
+ type ResourceFactory<T, D extends Record<string, Dependency>> = keyof D extends never ? (ctx: ExecutionContext) => MaybePromise<T> : (ctx: ExecutionContext, deps: InferDeps<D>) => MaybePromise<T>;
252
296
  type ServiceMethod = (ctx: ExecutionContext, ...args: any[]) => unknown;
253
297
  type ServiceMethods = Record<string, ServiceMethod>;
254
298
  /**
@@ -265,6 +309,10 @@ declare namespace Lite {
265
309
  * Any controller regardless of value type.
266
310
  */
267
311
  type AnyController = Controller<any>;
312
+ /**
313
+ * Any resource regardless of value type.
314
+ */
315
+ type AnyResource = Resource<any>;
268
316
  /**
269
317
  * Target type for wrapExec extension hook.
270
318
  * Either a Flow or an inline function.
@@ -531,24 +579,37 @@ declare function isAtom(value: unknown): value is Lite.Atom<unknown>;
531
579
  * The Controller provides full lifecycle control: get, resolve, release, invalidate, and subscribe.
532
580
  *
533
581
  * @param atom - The Atom to wrap
534
- * @param options - Optional configuration. Use { resolve: true } to auto-resolve before factory runs.
582
+ * @param options - Optional configuration:
583
+ * - `resolve: true` — auto-resolves the dep before the parent factory runs; `config.get()` is safe.
584
+ * - `watch: true` — atom deps only; requires `resolve: true`; automatically re-runs the parent factory
585
+ * when the dep resolves to a new value (value-equality gated via `Object.is` by default). Replaces
586
+ * manual `ctx.cleanup(ctx.scope.on('resolved', dep, () => ctx.invalidate()))` wiring. Watch
587
+ * listeners are auto-cleaned on re-resolve, release, and dispose.
588
+ * - `eq` — custom equality function `(a: T, b: T) => boolean`; only used with `watch: true`.
535
589
  * @returns A ControllerDep that resolves to a Controller for the Atom
536
590
  *
537
591
  * @example
538
592
  * ```typescript
539
- * const configAtom = atom({ factory: () => fetchConfig() })
593
+ * // resolve only
540
594
  * const serverAtom = atom({
541
595
  * deps: { config: controller(configAtom, { resolve: true }) },
542
- * factory: (ctx, { config }) => {
543
- * // config.get() is safe - already resolved
544
- * const unsub = config.on('resolved', () => ctx.invalidate())
545
- * ctx.cleanup(unsub)
546
- * return createServer(config.get().port)
547
- * }
596
+ * factory: (_, { config }) => createServer(config.get().port),
597
+ * })
598
+ *
599
+ * // watch: re-runs parent when dep value changes
600
+ * const profileAtom = atom({
601
+ * deps: { token: controller(tokenAtom, { resolve: true, watch: true }) },
602
+ * factory: (_, { token }) => ({ id: `user-${token.get().jwt}` }),
603
+ * })
604
+ *
605
+ * // watch with custom equality
606
+ * const derivedAtom = atom({
607
+ * deps: { src: controller(srcAtom, { resolve: true, watch: true, eq: (a, b) => a.id === b.id }) },
608
+ * factory: (_, { src }) => src.get().name,
548
609
  * })
549
610
  * ```
550
611
  */
551
- declare function controller<T>(atom: Lite.Atom<T>, options?: Lite.ControllerOptions): Lite.ControllerDep<T>;
612
+ declare function controller<T>(atom: Lite.Atom<T>, options?: Lite.ControllerDepOptions<T>): Lite.ControllerDep<T>;
552
613
  /**
553
614
  * Type guard to check if a value is a ControllerDep wrapper.
554
615
  *
@@ -621,7 +682,7 @@ declare function flow<TOutput, TInput>(config: {
621
682
  }) => MaybePromise<TOutput>;
622
683
  tags?: Lite.Tagged<any>[];
623
684
  }): Lite.Flow<TOutput, TInput>;
624
- declare function flow<TOutput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | {
685
+ declare function flow<TOutput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | Lite.Resource<unknown, Record<string, Lite.Dependency>> | {
625
686
  mode: string;
626
687
  }>>(config: {
627
688
  name?: string;
@@ -630,7 +691,7 @@ declare function flow<TOutput, const D extends Record<string, Lite.Atom<unknown>
630
691
  factory: (ctx: Lite.ExecutionContext, deps: Lite.InferDeps<D>) => MaybePromise<TOutput>;
631
692
  tags?: Lite.Tagged<any>[];
632
693
  }): Lite.Flow<TOutput, void>;
633
- declare function flow<TOutput, TInput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | {
694
+ declare function flow<TOutput, TInput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | Lite.Resource<unknown, Record<string, Lite.Dependency>> | {
634
695
  mode: string;
635
696
  }>>(config: {
636
697
  name?: string;
@@ -641,7 +702,7 @@ declare function flow<TOutput, TInput, const D extends Record<string, Lite.Atom<
641
702
  }, deps: Lite.InferDeps<D>) => MaybePromise<TOutput>;
642
703
  tags?: Lite.Tagged<any>[];
643
704
  }): Lite.Flow<TOutput, TInput>;
644
- declare function flow<TOutput, TInput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | {
705
+ declare function flow<TOutput, TInput, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | Lite.Resource<unknown, Record<string, Lite.Dependency>> | {
645
706
  mode: string;
646
707
  }>>(config: {
647
708
  name?: string;
@@ -721,6 +782,53 @@ declare function preset<TOutput, TInput>(target: Lite.Flow<TOutput, TInput>, val
721
782
  */
722
783
  declare function isPreset(value: unknown): value is Lite.Preset<unknown>;
723
784
  //#endregion
785
+ //#region src/resource.d.ts
786
+ /**
787
+ * Creates an execution-scoped dependency that is resolved per execution chain.
788
+ * Fresh instance on first encounter, seek-up on nested execs within the same chain.
789
+ *
790
+ * @param config - Configuration object containing factory function and optional dependencies
791
+ * @returns A Resource instance that can be declared as a dependency in flows and other resources
792
+ *
793
+ * @example
794
+ * ```typescript
795
+ * const requestLogger = resource({
796
+ * deps: { logService: logServiceAtom },
797
+ * factory: (ctx, { logService }) => {
798
+ * const logger = logService.child({ requestId: ctx.data.get("requestId") })
799
+ * ctx.onClose(() => logger.flush())
800
+ * return logger
801
+ * }
802
+ * })
803
+ * ```
804
+ */
805
+ declare function resource<T>(config: {
806
+ name?: string;
807
+ deps?: undefined;
808
+ factory: (ctx: Lite.ExecutionContext) => MaybePromise<T>;
809
+ }): Lite.Resource<T>;
810
+ declare function resource<T, const D extends Record<string, Lite.Atom<unknown> | Lite.ControllerDep<unknown> | Lite.Resource<unknown, Record<string, Lite.Dependency>> | {
811
+ mode: string;
812
+ }>>(config: {
813
+ name?: string;
814
+ deps: D;
815
+ factory: (ctx: Lite.ExecutionContext, deps: Lite.InferDeps<D>) => MaybePromise<T>;
816
+ }): Lite.Resource<T>;
817
+ /**
818
+ * Type guard to check if a value is a Resource.
819
+ *
820
+ * @param value - The value to check
821
+ * @returns True if the value is a Resource, false otherwise
822
+ *
823
+ * @example
824
+ * ```typescript
825
+ * if (isResource(value)) {
826
+ * // value is Lite.Resource<unknown>
827
+ * }
828
+ * ```
829
+ */
830
+ declare function isResource(value: unknown): value is Lite.Resource<unknown>;
831
+ //#endregion
724
832
  //#region src/service.d.ts
725
833
  /** Creates an atom with methods constrained to (ctx: ExecutionContext, ...args) => result. */
726
834
  declare function service<T extends Lite.ServiceMethods>(config: {
@@ -774,5 +882,5 @@ declare class ParseError extends Error {
774
882
  //#region src/index.d.ts
775
883
  declare const VERSION = "0.0.1";
776
884
  //#endregion
777
- export { type AtomState, type Lite, ParseError, VERSION, atom, atomSymbol, controller, controllerDepSymbol, controllerSymbol, createScope, flow, flowSymbol, getAllTags, isAtom, isControllerDep, isFlow, isPreset, isTag, isTagExecutor, isTagged, preset, presetSymbol, service, tag, tagExecutorSymbol, tagSymbol, taggedSymbol, tags, typed, typedSymbol };
885
+ export { type AtomState, type Lite, ParseError, VERSION, atom, atomSymbol, controller, controllerDepSymbol, controllerSymbol, createScope, flow, flowSymbol, getAllTags, isAtom, isControllerDep, isFlow, isPreset, isResource, isTag, isTagExecutor, isTagged, preset, presetSymbol, resource, resourceSymbol, service, tag, tagExecutorSymbol, tagSymbol, taggedSymbol, tags, typed, typedSymbol };
778
886
  //# sourceMappingURL=index.d.cts.map