@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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Lifecycle harness — start, stop, reload, health.
3
+ *
4
+ * Coordinates graceful shutdown across the Sentinel:
5
+ * - Registers SIGINT / SIGTERM / SIGHUP listeners on first `start()`.
6
+ * - SIGINT/SIGTERM trigger `stop(reason)` exactly once.
7
+ * - SIGHUP triggers `reload()`.
8
+ * - Hooks fire in registration order on start, reverse order on stop.
9
+ * - `health()` is a synchronous snapshot suitable for `/health` endpoints.
10
+ *
11
+ * Spec §5.3.
12
+ */
13
+
14
+ export type StopReason = 'sigint' | 'sigterm' | 'reload' | 'fatal' | 'manual';
15
+
16
+ export type LifecycleStatus = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
17
+
18
+ export interface HealthReport {
19
+ status: LifecycleStatus;
20
+ startedAt?: string;
21
+ stoppedAt?: string;
22
+ uptimeMs?: number;
23
+ hooks: { start: number; stop: number; reload: number };
24
+ lastError?: string;
25
+ }
26
+
27
+ export type StartHook = () => void | Promise<void>;
28
+ export type StopHook = (reason: StopReason) => void | Promise<void>;
29
+ export type ReloadHook = () => void | Promise<void>;
30
+
31
+ export interface Lifecycle {
32
+ start(): Promise<void>;
33
+ stop(reason?: StopReason): Promise<void>;
34
+ reload(): Promise<void>;
35
+ health(): HealthReport;
36
+
37
+ /** Register a hook that runs during `start()`. Returns an unsubscribe fn. */
38
+ onStart(hook: StartHook): () => void;
39
+ /** Register a hook that runs during `stop()`. Reverse-order. */
40
+ onStop(hook: StopHook): () => void;
41
+ /** Register a hook that runs during `reload()` / SIGHUP. */
42
+ onReload(hook: ReloadHook): () => void;
43
+ }
44
+
45
+ export interface CreateLifecycleOptions {
46
+ /**
47
+ * Whether to wire OS signal handlers (SIGINT/SIGTERM/SIGHUP) automatically.
48
+ * Defaults to true in production. Set false in tests.
49
+ */
50
+ installSignalHandlers?: boolean;
51
+ /** Optional log hook. */
52
+ log?: (level: 'info' | 'warn' | 'error', msg: string, meta?: Record<string, unknown>) => void;
53
+ }
54
+
55
+ /**
56
+ * Build a Lifecycle. The returned object is independent — multiple Sentinels
57
+ * in the same process each get their own (though OS signals will fan out).
58
+ */
59
+ export function createLifecycle(options: CreateLifecycleOptions = {}): Lifecycle {
60
+ const startHooks: StartHook[] = [];
61
+ const stopHooks: StopHook[] = [];
62
+ const reloadHooks: ReloadHook[] = [];
63
+ const installSignalHandlers = options.installSignalHandlers ?? true;
64
+ const log =
65
+ options.log ??
66
+ (() => {
67
+ /* default: silent */
68
+ });
69
+
70
+ let status: LifecycleStatus = 'idle';
71
+ let startedAt: Date | undefined;
72
+ let stoppedAt: Date | undefined;
73
+ let lastError: string | undefined;
74
+ let stopPromise: Promise<void> | undefined;
75
+ let signalHandlersInstalled = false;
76
+
77
+ const installHandlers = (lifecycle: Lifecycle): void => {
78
+ if (signalHandlersInstalled || !installSignalHandlers) return;
79
+ signalHandlersInstalled = true;
80
+
81
+ const handle = (reason: StopReason) => () => {
82
+ log('info', `lifecycle: received signal`, { reason });
83
+ void lifecycle.stop(reason).then(() => {
84
+ // Once everything has shut down, exit cleanly so the process doesn't hang.
85
+ process.exit(0);
86
+ });
87
+ };
88
+
89
+ process.once('SIGINT', handle('sigint'));
90
+ process.once('SIGTERM', handle('sigterm'));
91
+ process.on('SIGHUP', () => {
92
+ log('info', 'lifecycle: received SIGHUP, reloading');
93
+ void lifecycle.reload().catch((err) => {
94
+ log('error', 'lifecycle: reload failed', { err: String(err) });
95
+ });
96
+ });
97
+ };
98
+
99
+ const lifecycle: Lifecycle = {
100
+ async start() {
101
+ if (status === 'starting' || status === 'running') return;
102
+ status = 'starting';
103
+ lastError = undefined;
104
+ startedAt = new Date();
105
+ stoppedAt = undefined;
106
+
107
+ try {
108
+ for (const hook of startHooks) {
109
+ await hook();
110
+ }
111
+ status = 'running';
112
+ installHandlers(lifecycle);
113
+ log('info', 'lifecycle: started');
114
+ } catch (err) {
115
+ status = 'failed';
116
+ lastError = String(err);
117
+ log('error', 'lifecycle: start failed', { err: lastError });
118
+ throw err;
119
+ }
120
+ },
121
+
122
+ async stop(reason: StopReason = 'manual') {
123
+ if (status === 'stopped' || status === 'idle') return;
124
+ if (stopPromise) return stopPromise;
125
+
126
+ status = 'stopping';
127
+ log('info', 'lifecycle: stopping', { reason });
128
+
129
+ stopPromise = (async () => {
130
+ // Reverse order — last-in, first-out.
131
+ for (let i = stopHooks.length - 1; i >= 0; i -= 1) {
132
+ const hook = stopHooks[i]!;
133
+ try {
134
+ await hook(reason);
135
+ } catch (err) {
136
+ log('error', 'lifecycle: stop hook threw', { err: String(err) });
137
+ }
138
+ }
139
+ status = 'stopped';
140
+ stoppedAt = new Date();
141
+ log('info', 'lifecycle: stopped');
142
+ })();
143
+
144
+ return stopPromise;
145
+ },
146
+
147
+ async reload() {
148
+ if (status !== 'running') {
149
+ log('warn', 'lifecycle: reload requested but not running', { status });
150
+ return;
151
+ }
152
+ log('info', 'lifecycle: reloading');
153
+ for (const hook of reloadHooks) {
154
+ try {
155
+ await hook();
156
+ } catch (err) {
157
+ log('error', 'lifecycle: reload hook threw', { err: String(err) });
158
+ }
159
+ }
160
+ },
161
+
162
+ health(): HealthReport {
163
+ const report: HealthReport = {
164
+ status,
165
+ hooks: {
166
+ start: startHooks.length,
167
+ stop: stopHooks.length,
168
+ reload: reloadHooks.length,
169
+ },
170
+ };
171
+ if (startedAt) {
172
+ report.startedAt = startedAt.toISOString();
173
+ report.uptimeMs = Date.now() - startedAt.getTime();
174
+ }
175
+ if (stoppedAt) report.stoppedAt = stoppedAt.toISOString();
176
+ if (lastError) report.lastError = lastError;
177
+ return report;
178
+ },
179
+
180
+ onStart(hook) {
181
+ startHooks.push(hook);
182
+ return () => {
183
+ const idx = startHooks.indexOf(hook);
184
+ if (idx >= 0) startHooks.splice(idx, 1);
185
+ };
186
+ },
187
+
188
+ onStop(hook) {
189
+ stopHooks.push(hook);
190
+ return () => {
191
+ const idx = stopHooks.indexOf(hook);
192
+ if (idx >= 0) stopHooks.splice(idx, 1);
193
+ };
194
+ },
195
+
196
+ onReload(hook) {
197
+ reloadHooks.push(hook);
198
+ return () => {
199
+ const idx = reloadHooks.indexOf(hook);
200
+ if (idx >= 0) reloadHooks.splice(idx, 1);
201
+ };
202
+ },
203
+ };
204
+
205
+ return lifecycle;
206
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,52 @@
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
+
8
+ import pino, { type Logger as PinoLogger, type LoggerOptions } from 'pino';
9
+
10
+ export type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
11
+
12
+ export interface CreateLoggerOptions {
13
+ /** Component name used as the root binding (default: 'sentinel'). */
14
+ name?: string;
15
+ /** Log level. Reads $LOG_LEVEL or defaults to 'info'. */
16
+ level?: LogLevel;
17
+ /**
18
+ * If true, route through pino-pretty for human-readable dev output.
19
+ * Caller must install `pino-pretty` themselves.
20
+ */
21
+ pretty?: boolean;
22
+ /** Extra bindings included on every log line. */
23
+ bindings?: Record<string, unknown>;
24
+ }
25
+
26
+ /** A small re-export type so consumers don't need to depend on pino directly. */
27
+ export type Logger = PinoLogger;
28
+
29
+ /**
30
+ * Create a configured Pino logger. Convenient defaults; opts in to pretty
31
+ * printing only when explicitly requested.
32
+ */
33
+ export function createLogger(options: CreateLoggerOptions = {}): Logger {
34
+ const level: LogLevel = options.level ?? (process.env['LOG_LEVEL'] as LogLevel) ?? 'info';
35
+ const baseOptions: LoggerOptions = {
36
+ name: options.name ?? 'sentinel',
37
+ level,
38
+ ...(options.bindings ? { base: options.bindings } : {}),
39
+ };
40
+
41
+ if (options.pretty) {
42
+ return pino({
43
+ ...baseOptions,
44
+ transport: {
45
+ target: 'pino-pretty',
46
+ options: { colorize: true, translateTime: 'SYS:standard' },
47
+ },
48
+ });
49
+ }
50
+
51
+ return pino(baseOptions);
52
+ }
@@ -0,0 +1,53 @@
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
+ private tail: Promise<unknown> = Promise.resolve();
12
+ private pendingCount = 0;
13
+
14
+ /**
15
+ * Enqueue a task. The task runs after every previously-enqueued task
16
+ * resolves (or rejects). The returned promise resolves with the task's
17
+ * value, or rejects with the task's error.
18
+ *
19
+ * Importantly, errors do NOT poison the chain — subsequent tasks still run.
20
+ */
21
+ enqueue<T>(task: () => Promise<T> | T): Promise<T> {
22
+ this.pendingCount += 1;
23
+ const run = this.tail.then(
24
+ () => task(),
25
+ () => task() // never let a previous failure block the next task
26
+ );
27
+ // The chain itself absorbs errors so subsequent enqueues don't see them.
28
+ // Track pending count off the swallowed chain too — otherwise the unhandled
29
+ // rejection lands on `run.finally(...)` when the caller awaits before the
30
+ // microtask flush.
31
+ const tracked = run.catch(() => undefined).then(() => {
32
+ this.pendingCount -= 1;
33
+ });
34
+ this.tail = tracked;
35
+ return run as Promise<T>;
36
+ }
37
+
38
+ /**
39
+ * Resolve once the queue has fully drained (no pending tasks).
40
+ */
41
+ async drain(): Promise<void> {
42
+ while (this.pendingCount > 0) {
43
+ await this.tail;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Number of tasks currently queued or running.
49
+ */
50
+ get size(): number {
51
+ return this.pendingCount;
52
+ }
53
+ }
package/src/store.ts ADDED
@@ -0,0 +1,74 @@
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
+
13
+ import { promises as fs } from 'node:fs';
14
+ import { atomicWrite, type AtomicWriteOptions } from './atomic-write.js';
15
+ import { SerialQueue } from './serial-queue.js';
16
+
17
+ export interface StoreOptions<T> {
18
+ /** Absolute path to the JSON file backing this store. */
19
+ path: string;
20
+ /** Value returned by `read()` when the file does not yet exist. */
21
+ defaults: T;
22
+ /** Passed through to `atomicWrite` (mode, fsync, encoding). */
23
+ write?: AtomicWriteOptions;
24
+ /** `JSON.stringify` spacing. Defaults to 2 (pretty-printed). */
25
+ space?: number | string;
26
+ }
27
+
28
+ export interface Store<T> {
29
+ /** Read + parse the file. Returns a fresh clone of `defaults` if absent. */
30
+ read(): Promise<T>;
31
+ /** Atomically overwrite the file with `value`. Serialised. */
32
+ write(value: T): Promise<void>;
33
+ /** Read → apply `fn` → atomically write the result. Serialised end-to-end. */
34
+ update(fn: (current: T) => T): Promise<T>;
35
+ }
36
+
37
+ export function createStore<T>(options: StoreOptions<T>): Store<T> {
38
+ const { path, defaults, write: writeOptions, space = 2 } = options;
39
+ const queue = new SerialQueue();
40
+
41
+ async function readRaw(): Promise<T> {
42
+ try {
43
+ const text = await fs.readFile(path, 'utf8');
44
+ return JSON.parse(text) as T;
45
+ } catch (err) {
46
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
47
+ // Fresh clone so callers can't mutate the shared defaults object.
48
+ return structuredClone(defaults);
49
+ }
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ async function writeRaw(value: T): Promise<void> {
55
+ await atomicWrite(path, JSON.stringify(value, null, space), writeOptions);
56
+ }
57
+
58
+ return {
59
+ read() {
60
+ return queue.enqueue(readRaw);
61
+ },
62
+ write(value: T) {
63
+ return queue.enqueue(() => writeRaw(value));
64
+ },
65
+ update(fn: (current: T) => T) {
66
+ return queue.enqueue(async () => {
67
+ const current = await readRaw();
68
+ const next = fn(current);
69
+ await writeRaw(next);
70
+ return next;
71
+ });
72
+ },
73
+ };
74
+ }