@appydave/core 0.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.
package/dist/logger.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pino logger factory with child-logger support.
3
+ *
4
+ * Sentinels emit structured logs. Pino is fast, JSON-by-default, and supports
5
+ * child loggers without context-passing ceremony.
6
+ */
7
+ import pino from 'pino';
8
+ /**
9
+ * Create a configured Pino logger. Convenient defaults; opts in to pretty
10
+ * printing only when explicitly requested.
11
+ */
12
+ export function createLogger(options = {}) {
13
+ const level = options.level ?? process.env['LOG_LEVEL'] ?? 'info';
14
+ const baseOptions = {
15
+ name: options.name ?? 'sentinel',
16
+ level,
17
+ ...(options.bindings ? { base: options.bindings } : {}),
18
+ };
19
+ if (options.pretty) {
20
+ return pino({
21
+ ...baseOptions,
22
+ transport: {
23
+ target: 'pino-pretty',
24
+ options: { colorize: true, translateTime: 'SYS:standard' },
25
+ },
26
+ });
27
+ }
28
+ return pino(baseOptions);
29
+ }
30
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,IAAuD,MAAM,MAAM,CAAC;AAqB3E;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,UAA+B,EAAE;IAC5D,MAAM,KAAK,GAAa,OAAO,CAAC,KAAK,IAAK,OAAO,CAAC,GAAG,CAAC,WAAW,CAAc,IAAI,MAAM,CAAC;IAC1F,MAAM,WAAW,GAAkB;QACjC,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,UAAU;QAChC,KAAK;QACL,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACxD,CAAC;IAEF,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACnB,OAAO,IAAI,CAAC;YACV,GAAG,WAAW;YACd,SAAS,EAAE;gBACT,MAAM,EAAE,aAAa;gBACrB,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE;aAC3D;SACF,CAAC,CAAC;IACL,CAAC;IAED,OAAO,IAAI,CAAC,WAAW,CAAC,CAAC;AAC3B,CAAC"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SerialQueue — promise-chain serialisation primitive.
3
+ *
4
+ * Ensures tasks run in submission order, one at a time. Non-blocking to
5
+ * callers — `enqueue` returns a promise that resolves when the task completes.
6
+ *
7
+ * Use when ordered I/O matters (e.g. JSONL append, atomic file write,
8
+ * subprocess request stream).
9
+ */
10
+ export declare class SerialQueue {
11
+ private tail;
12
+ private pendingCount;
13
+ /**
14
+ * Enqueue a task. The task runs after every previously-enqueued task
15
+ * resolves (or rejects). The returned promise resolves with the task's
16
+ * value, or rejects with the task's error.
17
+ *
18
+ * Importantly, errors do NOT poison the chain — subsequent tasks still run.
19
+ */
20
+ enqueue<T>(task: () => Promise<T> | T): Promise<T>;
21
+ /**
22
+ * Resolve once the queue has fully drained (no pending tasks).
23
+ */
24
+ drain(): Promise<void>;
25
+ /**
26
+ * Number of tasks currently queued or running.
27
+ */
28
+ get size(): number;
29
+ }
30
+ //# sourceMappingURL=serial-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serial-queue.d.ts","sourceRoot":"","sources":["../src/serial-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,IAAI,CAAuC;IACnD,OAAO,CAAC,YAAY,CAAK;IAEzB;;;;;;OAMG;IACH,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAiBlD;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAM5B;;OAEG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * SerialQueue — promise-chain serialisation primitive.
3
+ *
4
+ * Ensures tasks run in submission order, one at a time. Non-blocking to
5
+ * callers — `enqueue` returns a promise that resolves when the task completes.
6
+ *
7
+ * Use when ordered I/O matters (e.g. JSONL append, atomic file write,
8
+ * subprocess request stream).
9
+ */
10
+ export class SerialQueue {
11
+ tail = Promise.resolve();
12
+ pendingCount = 0;
13
+ /**
14
+ * Enqueue a task. The task runs after every previously-enqueued task
15
+ * resolves (or rejects). The returned promise resolves with the task's
16
+ * value, or rejects with the task's error.
17
+ *
18
+ * Importantly, errors do NOT poison the chain — subsequent tasks still run.
19
+ */
20
+ enqueue(task) {
21
+ this.pendingCount += 1;
22
+ const run = this.tail.then(() => task(), () => task() // never let a previous failure block the next task
23
+ );
24
+ // The chain itself absorbs errors so subsequent enqueues don't see them.
25
+ // Track pending count off the swallowed chain too — otherwise the unhandled
26
+ // rejection lands on `run.finally(...)` when the caller awaits before the
27
+ // microtask flush.
28
+ const tracked = run.catch(() => undefined).then(() => {
29
+ this.pendingCount -= 1;
30
+ });
31
+ this.tail = tracked;
32
+ return run;
33
+ }
34
+ /**
35
+ * Resolve once the queue has fully drained (no pending tasks).
36
+ */
37
+ async drain() {
38
+ while (this.pendingCount > 0) {
39
+ await this.tail;
40
+ }
41
+ }
42
+ /**
43
+ * Number of tasks currently queued or running.
44
+ */
45
+ get size() {
46
+ return this.pendingCount;
47
+ }
48
+ }
49
+ //# sourceMappingURL=serial-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serial-queue.js","sourceRoot":"","sources":["../src/serial-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,OAAO,WAAW;IACd,IAAI,GAAqB,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3C,YAAY,GAAG,CAAC,CAAC;IAEzB;;;;;;OAMG;IACH,OAAO,CAAI,IAA0B;QACnC,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC;QACvB,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CACxB,GAAG,EAAE,CAAC,IAAI,EAAE,EACZ,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,mDAAmD;SACjE,CAAC;QACF,yEAAyE;QACzE,4EAA4E;QAC5E,0EAA0E;QAC1E,mBAAmB;QACnB,MAAM,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE;YACnD,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC;QACzB,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC;QACpB,OAAO,GAAiB,CAAC;IAC3B,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,KAAK;QACT,OAAO,IAAI,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;YAC7B,MAAM,IAAI,CAAC,IAAI,CAAC;QAClB,CAAC;IACH,CAAC;IAED;;OAEG;IACH,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,YAAY,CAAC;IAC3B,CAAC;CACF"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Store — local-first JSON persistence.
3
+ *
4
+ * A thin, crash-safe wrapper over `atomicWrite` (torn-write-proof file writes)
5
+ * and `SerialQueue` (ordered, one-at-a-time access). Every read/write/update is
6
+ * serialised through a single queue, so concurrent `update()` calls never lose a
7
+ * write — the classic read-modify-write race is eliminated.
8
+ *
9
+ * Not a database: one JSON document per Store. For structured/queryable data a
10
+ * consumer reaches for SQLite (a per-product recipe), not this.
11
+ */
12
+ import { type AtomicWriteOptions } from './atomic-write.js';
13
+ export interface StoreOptions<T> {
14
+ /** Absolute path to the JSON file backing this store. */
15
+ path: string;
16
+ /** Value returned by `read()` when the file does not yet exist. */
17
+ defaults: T;
18
+ /** Passed through to `atomicWrite` (mode, fsync, encoding). */
19
+ write?: AtomicWriteOptions;
20
+ /** `JSON.stringify` spacing. Defaults to 2 (pretty-printed). */
21
+ space?: number | string;
22
+ }
23
+ export interface Store<T> {
24
+ /** Read + parse the file. Returns a fresh clone of `defaults` if absent. */
25
+ read(): Promise<T>;
26
+ /** Atomically overwrite the file with `value`. Serialised. */
27
+ write(value: T): Promise<void>;
28
+ /** Read → apply `fn` → atomically write the result. Serialised end-to-end. */
29
+ update(fn: (current: T) => T): Promise<T>;
30
+ }
31
+ export declare function createStore<T>(options: StoreOptions<T>): Store<T>;
32
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAe,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAGzE,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,QAAQ,EAAE,CAAC,CAAC;IACZ,+DAA+D;IAC/D,KAAK,CAAC,EAAE,kBAAkB,CAAC;IAC3B,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,4EAA4E;IAC5E,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;IACnB,8DAA8D;IAC9D,KAAK,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC3C;AAED,wBAAgB,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAqCjE"}
package/dist/store.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Store — local-first JSON persistence.
3
+ *
4
+ * A thin, crash-safe wrapper over `atomicWrite` (torn-write-proof file writes)
5
+ * and `SerialQueue` (ordered, one-at-a-time access). Every read/write/update is
6
+ * serialised through a single queue, so concurrent `update()` calls never lose a
7
+ * write — the classic read-modify-write race is eliminated.
8
+ *
9
+ * Not a database: one JSON document per Store. For structured/queryable data a
10
+ * consumer reaches for SQLite (a per-product recipe), not this.
11
+ */
12
+ import { promises as fs } from 'node:fs';
13
+ import { atomicWrite } from './atomic-write.js';
14
+ import { SerialQueue } from './serial-queue.js';
15
+ export function createStore(options) {
16
+ const { path, defaults, write: writeOptions, space = 2 } = options;
17
+ const queue = new SerialQueue();
18
+ async function readRaw() {
19
+ try {
20
+ const text = await fs.readFile(path, 'utf8');
21
+ return JSON.parse(text);
22
+ }
23
+ catch (err) {
24
+ if (err.code === 'ENOENT') {
25
+ // Fresh clone so callers can't mutate the shared defaults object.
26
+ return structuredClone(defaults);
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+ async function writeRaw(value) {
32
+ await atomicWrite(path, JSON.stringify(value, null, space), writeOptions);
33
+ }
34
+ return {
35
+ read() {
36
+ return queue.enqueue(readRaw);
37
+ },
38
+ write(value) {
39
+ return queue.enqueue(() => writeRaw(value));
40
+ },
41
+ update(fn) {
42
+ return queue.enqueue(async () => {
43
+ const current = await readRaw();
44
+ const next = fn(current);
45
+ await writeRaw(next);
46
+ return next;
47
+ });
48
+ },
49
+ };
50
+ }
51
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EAAE,WAAW,EAA2B,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAsBhD,MAAM,UAAU,WAAW,CAAI,OAAwB;IACrD,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,OAAO,CAAC;IACnE,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE,CAAC;IAEhC,KAAK,UAAU,OAAO;QACpB,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC7C,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAM,CAAC;QAC/B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACrD,kEAAkE;gBAClE,OAAO,eAAe,CAAC,QAAQ,CAAC,CAAC;YACnC,CAAC;YACD,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,KAAK,UAAU,QAAQ,CAAC,KAAQ;QAC9B,MAAM,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,YAAY,CAAC,CAAC;IAC5E,CAAC;IAED,OAAO;QACL,IAAI;YACF,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAChC,CAAC;QACD,KAAK,CAAC,KAAQ;YACZ,OAAO,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;QAC9C,CAAC;QACD,MAAM,CAAC,EAAqB;YAC1B,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE;gBAC9B,MAAM,OAAO,GAAG,MAAM,OAAO,EAAE,CAAC;gBAChC,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC;gBACzB,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;gBACrB,OAAO,IAAI,CAAC;YACd,CAAC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@appydave/core",
3
+ "version": "0.1.0",
4
+ "description": "AppyDave shared foundation primitives — Lifecycle, ConfigLoader, Logger, atomicWrite, SerialQueue, Store. Neutral (no product prefix); reused by AppyStack, AppySentinel, AppyTron.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./lifecycle": {
18
+ "types": "./dist/lifecycle.d.ts",
19
+ "import": "./dist/lifecycle.js"
20
+ },
21
+ "./config": {
22
+ "types": "./dist/config.d.ts",
23
+ "import": "./dist/config.js"
24
+ },
25
+ "./logger": {
26
+ "types": "./dist/logger.d.ts",
27
+ "import": "./dist/logger.js"
28
+ },
29
+ "./atomic-write": {
30
+ "types": "./dist/atomic-write.d.ts",
31
+ "import": "./dist/atomic-write.js"
32
+ },
33
+ "./serial-queue": {
34
+ "types": "./dist/serial-queue.d.ts",
35
+ "import": "./dist/serial-queue.js"
36
+ },
37
+ "./store": {
38
+ "types": "./dist/store.d.ts",
39
+ "import": "./dist/store.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist/",
44
+ "src/",
45
+ "README.md"
46
+ ],
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json",
49
+ "dev": "tsc -p tsconfig.json --watch",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "typecheck": "tsc --noEmit -p tsconfig.json",
53
+ "lint": "echo 'core lint stub' && exit 0",
54
+ "prepublishOnly": "bun run build"
55
+ },
56
+ "dependencies": {
57
+ "pino": "^9.5.0",
58
+ "zod": "^3.23.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^22.10.0",
62
+ "typescript": "^5.7.0",
63
+ "vitest": "^2.1.0"
64
+ },
65
+ "keywords": [
66
+ "appydave",
67
+ "foundation",
68
+ "core",
69
+ "lifecycle",
70
+ "config",
71
+ "logger",
72
+ "store",
73
+ "atomic-write",
74
+ "bun",
75
+ "node"
76
+ ],
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/appydave/foundation.git",
80
+ "directory": "packages/core"
81
+ },
82
+ "homepage": "https://github.com/appydave/foundation#readme",
83
+ "author": "David Cruwys",
84
+ "license": "MIT"
85
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Atomic file write helper.
3
+ *
4
+ * Pattern: write to a sibling temp file, then `rename` into place. On POSIX
5
+ * filesystems (and APFS / NTFS via Node's promisified fs.rename) this is
6
+ * atomic at the directory-entry level — readers either see the previous
7
+ * version or the new one, never a torn write.
8
+ *
9
+ * Essential for crash safety with flat-file stores (JSONL indexes, registry
10
+ * snapshots, config files).
11
+ */
12
+
13
+ import { promises as fs } from 'node:fs';
14
+ import { dirname, basename, join } from 'node:path';
15
+ import { randomBytes } from 'node:crypto';
16
+
17
+ export interface AtomicWriteOptions {
18
+ /** File mode (e.g. 0o600). Defaults to system default. */
19
+ mode?: number;
20
+ /** Encoding for string content. Defaults to 'utf8'. */
21
+ encoding?: BufferEncoding;
22
+ /**
23
+ * fsync the file before rename. Slower but stronger crash guarantee.
24
+ * Defaults to false.
25
+ */
26
+ fsync?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Atomically write content to `path`.
31
+ *
32
+ * Behaviour:
33
+ * 1. Write to `<path>.<random>.tmp` in the same directory.
34
+ * 2. Optionally fsync.
35
+ * 3. `rename` over the destination.
36
+ * 4. On any error, the temp file is removed on a best-effort basis.
37
+ */
38
+ export async function atomicWrite(
39
+ path: string,
40
+ content: string | Uint8Array,
41
+ options: AtomicWriteOptions = {}
42
+ ): Promise<void> {
43
+ const dir = dirname(path);
44
+ const base = basename(path);
45
+ const suffix = randomBytes(6).toString('hex');
46
+ const tmp = join(dir, `.${base}.${suffix}.tmp`);
47
+
48
+ let handle: fs.FileHandle | undefined;
49
+ try {
50
+ handle = await fs.open(tmp, 'w', options.mode);
51
+
52
+ if (typeof content === 'string') {
53
+ await handle.writeFile(content, { encoding: options.encoding ?? 'utf8' });
54
+ } else {
55
+ await handle.writeFile(content);
56
+ }
57
+
58
+ if (options.fsync) {
59
+ await handle.sync();
60
+ }
61
+
62
+ await handle.close();
63
+ handle = undefined;
64
+
65
+ await fs.rename(tmp, path);
66
+ } catch (err) {
67
+ if (handle) {
68
+ try {
69
+ await handle.close();
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+ try {
75
+ await fs.unlink(tmp);
76
+ } catch {
77
+ /* best-effort cleanup */
78
+ }
79
+ throw err;
80
+ }
81
+ }
package/src/config.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Hierarchical config loader: built-in defaults → env vars → config file.
3
+ *
4
+ * Validated through a Zod schema. Optional file-watch reloads via SIGHUP or
5
+ * a chokidar watcher driven externally.
6
+ *
7
+ * Spec §5.4.
8
+ */
9
+
10
+ import { promises as fs } from 'node:fs';
11
+ import { z } from 'zod';
12
+
13
+ export interface ConfigLoaderOptions<T> {
14
+ /** Zod schema describing the merged config. */
15
+ schema: z.ZodType<T>;
16
+
17
+ /** Static defaults applied first. */
18
+ defaults?: Partial<T>;
19
+
20
+ /**
21
+ * Map of env var names → object paths to layer in.
22
+ *
23
+ * Example: `{ LOG_LEVEL: 'logger.level' }` reads `process.env.LOG_LEVEL`
24
+ * and sets `config.logger.level`. Values are strings; coerce inside the
25
+ * Zod schema (via `z.coerce.number()` etc).
26
+ */
27
+ env?: Record<string, string>;
28
+
29
+ /** Optional path to a JSON config file. Missing file is not an error. */
30
+ filePath?: string;
31
+
32
+ /**
33
+ * Custom env source (defaults to process.env). Useful for tests.
34
+ */
35
+ envSource?: NodeJS.ProcessEnv;
36
+ }
37
+
38
+ export interface ConfigLoader<T> {
39
+ /** Load + validate the merged config. Throws on schema errors. */
40
+ load(): Promise<T>;
41
+
42
+ /**
43
+ * Re-read the config file and re-merge env. Returns the new config.
44
+ * Subscribers registered via `onChange` are notified.
45
+ */
46
+ reload(): Promise<T>;
47
+
48
+ /** Register a change handler. Returns an unsubscribe function. */
49
+ onChange(handler: (config: T) => void): () => void;
50
+
51
+ /** Most recently loaded config, if any. */
52
+ current(): T | undefined;
53
+ }
54
+
55
+ function setPath(target: Record<string, unknown>, path: string, value: unknown): void {
56
+ const segments = path.split('.');
57
+ let node: Record<string, unknown> = target;
58
+ for (let i = 0; i < segments.length - 1; i += 1) {
59
+ const seg = segments[i]!;
60
+ const next = node[seg];
61
+ if (next === undefined || next === null || typeof next !== 'object') {
62
+ const fresh: Record<string, unknown> = {};
63
+ node[seg] = fresh;
64
+ node = fresh;
65
+ } else {
66
+ node = next as Record<string, unknown>;
67
+ }
68
+ }
69
+ node[segments[segments.length - 1]!] = value;
70
+ }
71
+
72
+ /** Deep merge — plain objects only, arrays replace. */
73
+ function deepMerge(
74
+ base: Record<string, unknown>,
75
+ overlay: Record<string, unknown>
76
+ ): Record<string, unknown> {
77
+ const out: Record<string, unknown> = { ...base };
78
+ for (const [key, value] of Object.entries(overlay)) {
79
+ const existing = out[key];
80
+ if (
81
+ existing &&
82
+ typeof existing === 'object' &&
83
+ !Array.isArray(existing) &&
84
+ value &&
85
+ typeof value === 'object' &&
86
+ !Array.isArray(value)
87
+ ) {
88
+ out[key] = deepMerge(
89
+ existing as Record<string, unknown>,
90
+ value as Record<string, unknown>
91
+ );
92
+ } else {
93
+ out[key] = value;
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ async function readJsonFile(path: string): Promise<Record<string, unknown> | undefined> {
100
+ try {
101
+ const raw = await fs.readFile(path, 'utf8');
102
+ const parsed = JSON.parse(raw);
103
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
104
+ return parsed as Record<string, unknown>;
105
+ }
106
+ throw new Error(`config file ${path} must contain a JSON object`);
107
+ } catch (err) {
108
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Create a hierarchical config loader.
115
+ *
116
+ * Layering order (later overrides earlier):
117
+ * 1. `options.defaults`
118
+ * 2. JSON file at `options.filePath` (if present)
119
+ * 3. environment variables mapped via `options.env`
120
+ *
121
+ * Validation runs once at the end against `options.schema`.
122
+ */
123
+ export function createConfigLoader<T>(options: ConfigLoaderOptions<T>): ConfigLoader<T> {
124
+ const handlers = new Set<(config: T) => void>();
125
+ let lastConfig: T | undefined;
126
+ const envSource = options.envSource ?? process.env;
127
+
128
+ const buildOnce = async (): Promise<T> => {
129
+ let merged: Record<string, unknown> = (options.defaults ?? {}) as Record<string, unknown>;
130
+
131
+ if (options.filePath) {
132
+ const fileLayer = await readJsonFile(options.filePath);
133
+ if (fileLayer) merged = deepMerge(merged, fileLayer);
134
+ }
135
+
136
+ if (options.env) {
137
+ const envOverlay: Record<string, unknown> = {};
138
+ for (const [varName, dottedPath] of Object.entries(options.env)) {
139
+ const raw = envSource[varName];
140
+ if (raw !== undefined) {
141
+ setPath(envOverlay, dottedPath, raw);
142
+ }
143
+ }
144
+ if (Object.keys(envOverlay).length > 0) {
145
+ merged = deepMerge(merged, envOverlay);
146
+ }
147
+ }
148
+
149
+ return options.schema.parse(merged);
150
+ };
151
+
152
+ return {
153
+ async load() {
154
+ const cfg = await buildOnce();
155
+ lastConfig = cfg;
156
+ return cfg;
157
+ },
158
+
159
+ async reload() {
160
+ const cfg = await buildOnce();
161
+ lastConfig = cfg;
162
+ for (const handler of handlers) {
163
+ try {
164
+ handler(cfg);
165
+ } catch (err) {
166
+ // eslint-disable-next-line no-console
167
+ console.error('[appysentinel] config onChange handler error', err);
168
+ }
169
+ }
170
+ return cfg;
171
+ },
172
+
173
+ onChange(handler) {
174
+ handlers.add(handler);
175
+ return () => {
176
+ handlers.delete(handler);
177
+ };
178
+ },
179
+
180
+ current() {
181
+ return lastConfig;
182
+ },
183
+ };
184
+ }
185
+
186
+ export { z };
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @appydave/core — shared AppyDave foundation primitives.
3
+ *
4
+ * Cross-cutting building blocks reused by every AppyDave boilerplate
5
+ * (AppyStack, AppySentinel, AppyTron): process lifecycle, config loading,
6
+ * structured logging, atomic file writes, a serial task queue, and a
7
+ * local-first JSON store. No product-specific or Electron code lives here.
8
+ */
9
+
10
+ export {
11
+ type Lifecycle,
12
+ type LifecycleStatus,
13
+ type StopReason,
14
+ type HealthReport,
15
+ type StartHook,
16
+ type StopHook,
17
+ type ReloadHook,
18
+ type CreateLifecycleOptions,
19
+ createLifecycle,
20
+ } from './lifecycle.js';
21
+
22
+ export {
23
+ type ConfigLoader,
24
+ type ConfigLoaderOptions,
25
+ createConfigLoader,
26
+ z,
27
+ } from './config.js';
28
+
29
+ export {
30
+ createLogger,
31
+ type Logger,
32
+ type LogLevel,
33
+ type CreateLoggerOptions,
34
+ } from './logger.js';
35
+
36
+ export { atomicWrite, type AtomicWriteOptions } from './atomic-write.js';
37
+
38
+ export { SerialQueue } from './serial-queue.js';
39
+
40
+ export { createStore, type Store, type StoreOptions } from './store.js';