-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathsettings-client.zod.ts
More file actions
150 lines (134 loc) · 6.15 KB
/
Copy pathsettings-client.zod.ts
File metadata and controls
150 lines (134 loc) · 6.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* SettingsClient — reactive consumer contract for runtime settings.
*
* Background. Phase 0 introduced the cascade scope (`env > global >
* tenant > user > default`) and made `SettingsService.get()` resolve
* across the chain. Consumers (e.g. EmailServicePlugin, BrandingPlugin)
* still pull values once at boot, which means saved changes never take
* effect without a process restart.
*
* This module defines the contract that fixes that:
*
* const mail = ctx.settings.bind('mail', MailSettingsSchema);
* mail.current.smtp_host; // current effective value
* const off = mail.onChange(() => rebuild());
*
* service.set('mail', 'smtp_host', '…') // ↳ fires settings:changed
* // → handler runs → transport rebuilt
*
* Design rules:
*
* 1. **Spec-only.** This package emits types and Zod shapes. No runtime
* wiring (event bus, in-memory cache) lives here — that is
* `@objectstack/service-settings`'s job. Keeping the contract pure
* lets non-Node consumers (RN, edge workers) re-implement it.
*
* 2. **Snapshot semantics.** `current` is an immutable snapshot of the
* namespace at the moment of the last refresh. After a change event
* fires, the next read of `current` returns the new snapshot — old
* references stay stable (good for React useSyncExternalStore).
*
* 3. **No fetching here.** A `SettingsClient` does not know how to fetch
* itself; it is constructed by an authority (the service) that owns
* the persistence layer. That keeps cycles out of the dependency
* graph and lets us mock the client trivially in tests.
*/
import { z } from 'zod';
import { lazySchema } from '../shared/lazy-schema';
import { SpecifierScopeSchema } from './settings-manifest.zod';
// ---------------------------------------------------------------------------
// Change event
// ---------------------------------------------------------------------------
/**
* Emitted on the `settings:changed` channel each time `SettingsService.set`
* mutates a row. The event is the minimum payload a consumer needs to
* decide whether to refresh — full resolved values are obtained by
* re-reading `client.current`.
*
* `source` mirrors `ResolvedSettingValue.source` and tells the consumer
* which scope row was actually mutated; this allows finer-grained reaction
* (a user-scope change shouldn't rebuild a global transport, etc.).
*/
export const SettingsChangeEventSchema = lazySchema(() => z.object({
/** Namespace whose value changed (matches `SettingsManifest.namespace`). */
namespace: z.string().min(1).describe('Settings namespace, e.g. "mail"'),
/** Specifier key that changed (matches `Specifier.key`). */
key: z.string().min(1).describe('Specifier key, e.g. "smtp_host"'),
/** Scope of the row that was written (where the mutation happened). */
scope: SpecifierScopeSchema.describe('Scope of the mutated row'),
/**
* `'set'` when a value was written or replaced; `'reset'` when the row
* was cleared (the cascade chain now resolves to the next layer down).
*/
action: z.enum(['set', 'reset']).describe('Mutation kind'),
/** Wall-clock timestamp (ISO 8601) when the mutation completed. */
at: z.string().describe('ISO 8601 mutation timestamp'),
}));
export type SettingsChangeEvent = z.infer<typeof SettingsChangeEventSchema>;
/**
* Bus identifier on which `SettingsChangeEvent`s are published.
* Exported as a constant so consumers and the service agree on the channel.
*/
export const SETTINGS_CHANGE_EVENT = 'settings:changed' as const;
export type SettingsChangeEventName = typeof SETTINGS_CHANGE_EVENT;
/** Unsubscribe handle returned by `onChange`. Idempotent. */
export type SettingsUnsubscribe = () => void;
/**
* Handler signature for change subscribers. Sync-only by contract —
* use the handler to enqueue async work yourself (e.g. queue a
* `rebuildTransport()` task) so the bus stays cheap.
*/
export type SettingsChangeHandler = (event: SettingsChangeEvent) => void;
// ---------------------------------------------------------------------------
// Reactive client
// ---------------------------------------------------------------------------
/**
* Reactive view over a single settings namespace.
*
* Lifecycle:
* - obtained via `ctx.settings.bind(ns, schema)` (Phase 1 capability)
* - `current` is hydrated synchronously from the service's in-memory cache
* - `onChange(handler)` registers for `settings:changed` events scoped to `ns`
* - the returned `SettingsUnsubscribe` MUST be called by the plugin's
* `shutdown` hook to avoid leaks
*
* The generic `T` is the inferred shape of the namespace's Zod schema
* (see `bind` below). All getters return validated, defaulted values.
*/
export interface ISettingsClient<T extends Record<string, unknown>> {
/** Namespace this client is bound to. Immutable. */
readonly namespace: string;
/**
* The current effective snapshot. Reading is O(1); the underlying
* map is replaced atomically on every change so prior references
* stay valid (use the latest `current` to read fresh state).
*/
readonly current: T;
/** Resolve a single key. Equivalent to `client.current[key]`. */
get<K extends keyof T>(key: K): T[K];
/**
* Subscribe to changes within this namespace. The handler fires
* after `current` has been swapped, so reading `current` inside the
* handler observes the new value.
*/
onChange(handler: SettingsChangeHandler): SettingsUnsubscribe;
}
/**
* Factory / capability shape exposed to plugins as `ctx.settings`.
*
* Why a factory and not just `getClient(ns)`? Binding ties a Zod schema
* to the namespace so consumers get strong types and runtime validation
* for free; the service can also cache the parser per namespace.
*/
export interface ISettingsCapability {
/**
* Bind a namespace to a Zod schema and return a typed reactive client.
*
* @param namespace Settings namespace (matches `SettingsManifest.namespace`)
* @param schema Zod schema for the namespace's full value object
*/
bind<S extends z.ZodTypeAny>(
namespace: string,
schema: S,
): ISettingsClient<z.infer<S> & Record<string, unknown>>;
}