@smplkit/sdk 3.0.3 → 3.0.5

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/README.md CHANGED
@@ -16,34 +16,40 @@ npm install @smplkit/sdk
16
16
 
17
17
  ## Quick Start
18
18
 
19
+ `SmplClient` requires `apiKey`, `environment`, and `service`. Each can come from the constructor, an environment variable, or `~/.smplkit`.
20
+
19
21
  ```typescript
20
22
  import { SmplClient } from "@smplkit/sdk";
21
23
 
22
- // Option 1: Explicit API key
23
- const client = new SmplClient({ apiKey: "sk_api_..." });
24
+ const client = new SmplClient({
25
+ apiKey: "sk_api_...",
26
+ environment: "production",
27
+ service: "my-service",
28
+ });
29
+
30
+ // Block until cache is warm and the live-updates WebSocket is connected.
31
+ // Optional but recommended at process start so the first reads hit cache.
32
+ await client.waitUntilReady();
24
33
 
25
- // Option 2: Environment variable (SMPLKIT_API_KEY)
26
- // export SMPLKIT_API_KEY=sk_api_...
27
- const client2 = new SmplClient();
34
+ // ... do work ...
28
35
 
29
- // Option 3: Configuration file (~/.smplkit)
30
- // [default]
31
- // api_key = sk_api_...
32
- const client3 = new SmplClient();
36
+ client.close(); // releases the WebSocket and stops background timers
33
37
  ```
34
38
 
39
+ If `SMPLKIT_API_KEY` / `SMPLKIT_ENVIRONMENT` / `SMPLKIT_SERVICE` are set (or a `~/.smplkit` profile supplies them), `new SmplClient()` works with no arguments.
40
+
35
41
  ## Configuration
36
42
 
37
- All settings are resolved from three sources, in order of precedence:
43
+ Settings are resolved in order of precedence:
38
44
 
39
- 1. **Constructor options** — highest priority, always wins.
40
- 2. **Environment variables** — e.g. `SMPLKIT_API_KEY`, `SMPLKIT_ENVIRONMENT`.
45
+ 1. **Constructor options** — highest priority.
46
+ 2. **Environment variables** — `SMPLKIT_API_KEY`, `SMPLKIT_ENVIRONMENT`, `SMPLKIT_SERVICE`, `SMPLKIT_BASE_DOMAIN`, `SMPLKIT_SCHEME`, `SMPLKIT_DEBUG`, `SMPLKIT_DISABLE_TELEMETRY`, `SMPLKIT_PROFILE`.
41
47
  3. **Configuration file** (`~/.smplkit`) — INI-format with profile support.
42
- 4. **Defaults** — built-in SDK defaults.
48
+ 4. **Built-in defaults**.
43
49
 
44
50
  ### Configuration File
45
51
 
46
- The `~/.smplkit` file supports a `[common]` section (applied to all profiles) and named profiles:
52
+ `~/.smplkit` supports a `[common]` section (applied to every profile) plus named profiles:
47
53
 
48
54
  ```ini
49
55
  [common]
@@ -61,202 +67,291 @@ environment = development
61
67
  debug = true
62
68
  ```
63
69
 
64
- ### Constructor Examples
65
-
66
70
  ```typescript
67
- // Use a named profile
68
71
  const client = new SmplClient({ profile: "local" });
69
-
70
- // Or configure explicitly
71
- const client = new SmplClient({
72
- apiKey: "sk_api_...",
73
- environment: "production",
74
- service: "my-service",
75
- });
76
72
  ```
77
73
 
78
- For the complete configuration reference, see the [Configuration Guide](https://docs.smplkit.com/getting-started/configuration).
74
+ For the complete reference, see the [Configuration Guide](https://docs.smplkit.com/getting-started/configuration).
79
75
 
80
76
  ## Config
81
77
 
82
- ### Runtime (resolve config values)
78
+ ### Runtime resolve config values
79
+
80
+ `client.config.get(key)` returns a `LiveConfigProxy`: a read-only, dict-like view that always reflects the latest server-pushed values.
83
81
 
84
82
  ```typescript
85
- // Resolve config values for a service
86
- const config = await client.config.get("my-service");
87
- console.log(config.getString("timeout"));
88
- console.log(config.getNumber("retries"));
89
-
90
- // Subscribe to live updates
91
- client.config.subscribe("my-service", (config) => {
92
- console.log("Config updated:", config.getString("timeout"));
93
- });
83
+ const cfg = await client.config.get("user-service");
84
+
85
+ // Dict-style access — both forms work
86
+ console.log(cfg.get("database.host"));
87
+ console.log(cfg["max_retries"]);
88
+ for (const key of Object.keys(cfg)) console.log(key, cfg[key]);
89
+
90
+ // Per-config and per-item change listeners
91
+ cfg.onChange((event) => console.log(`${event.itemKey}: ${event.oldValue} -> ${event.newValue}`));
92
+ cfg.onChange("max_retries", (event) => console.log("retries changed:", event.newValue));
93
+
94
+ // Or attach a global listener that fires for any config change
95
+ client.config.onChange((event) => console.log(`${event.configId}.${event.itemKey} changed`));
96
+
97
+ // Manual re-fetch (useful after suspecting drift)
98
+ await client.config.refresh();
99
+ ```
100
+
101
+ You can also pass a model class as the second argument; the proxy reconstructs the model from the latest values on every read so attribute access type-checks against your model:
102
+
103
+ ```typescript
104
+ class UserServiceConfig {
105
+ database!: { host: string; port: number };
106
+ max_retries!: number;
107
+ constructor(data: any) {
108
+ Object.assign(this, data);
109
+ }
110
+ }
111
+ const typed = await client.config.get("user-service", UserServiceConfig);
112
+ console.log(typed.database.host);
94
113
  ```
95
114
 
96
115
  ### Management (CRUD)
97
116
 
117
+ CRUD lives under `client.manage.config.*`. You can also construct a standalone `SmplManagementClient` for setup scripts or admin tooling.
118
+
98
119
  ```typescript
99
- // Create a config
100
- const cfg = client.config.management.new("my-service", {
120
+ // Author a config — `set*` mutations are local until `.save()` is called.
121
+ const cfg = client.manage.config.new("my-service", {
101
122
  name: "My Service",
102
123
  description: "Configuration for my service",
103
124
  });
125
+ cfg.setString("database.host", "localhost");
126
+ cfg.setNumber("max_retries", 3);
127
+ cfg.setBoolean("enable_signup", true);
128
+ cfg.setJson("feature_matrix", { v2: true });
129
+
130
+ // Per-environment override
131
+ cfg.setNumber("max_retries", 5, { environment: "production" });
104
132
  await cfg.save();
105
133
 
106
- // List configs
107
- const configs = await client.config.management.list();
134
+ // Read / list / delete
135
+ const fetched = await client.manage.config.get("my-service");
136
+ const all = await client.manage.config.list();
137
+ await client.manage.config.delete("my-service");
138
+ ```
108
139
 
109
- // Get a config by id
110
- const fetched = await client.config.management.get("my-service");
140
+ A config can have a `parent`; resolved values from the parent are inherited and overridden by the child. The hierarchy is at most two levels deep — the server rejects a parent that itself has a parent.
111
141
 
112
- // Delete a config
113
- await client.config.management.delete("my-service");
142
+ ```typescript
143
+ const child = client.manage.config.new("user-service", {
144
+ name: "User Service",
145
+ parent: "my-service", // or pass a Config instance
146
+ });
114
147
  ```
115
148
 
116
149
  ## Flags
117
150
 
118
- ### Runtime (evaluate flags)
151
+ ### Runtime evaluate flags
119
152
 
120
153
  ```typescript
121
- import { SmplClient, Rule } from "@smplkit/sdk";
154
+ import { SmplClient, Context } from "@smplkit/sdk";
122
155
 
123
156
  const client = new SmplClient({ environment: "production", service: "my-service" });
157
+ await client.waitUntilReady();
158
+
159
+ // Declare typed flag handles. The default is returned when smplkit is
160
+ // unreachable or the flag does not exist.
161
+ const checkoutV2 = client.flags.booleanFlag("checkout-v2", false);
162
+ const bannerColor = client.flags.stringFlag("banner-color", "red");
163
+ const maxRetries = client.flags.numberFlag("max-retries", 3);
164
+
165
+ // Evaluate with explicit per-call context
166
+ const enabled = checkoutV2.get({
167
+ context: [
168
+ new Context("user", "alice@acme.com", { plan: "enterprise" }),
169
+ new Context("account", "1234", { region: "us" }),
170
+ ],
171
+ });
124
172
 
125
- // Declare a flag
126
- const checkoutFlag = client.flags.booleanFlag("checkout-v2", { default: false });
173
+ // Or register an ambient context provider that fires per evaluation
174
+ client.flags.setContextProvider(() => [
175
+ new Context("user", currentUser.email, { plan: currentUser.plan }),
176
+ ]);
177
+ const colour = bannerColor.get(); // uses the provider
178
+ ```
179
+
180
+ `flag.get()` is synchronous — `initialize()` (or `waitUntilReady()`) populates the local store; subsequent reads hit cache and never block.
127
181
 
128
- // Start the client (connects and fetches flags)
129
- await client.flags.initialize();
182
+ #### Listening for changes
183
+
184
+ ```typescript
185
+ client.flags.onChange((event) => console.log(`${event.id} changed`));
186
+ client.flags.onChange("banner-color", (event) => console.log("banner-color updated"));
130
187
 
131
- // Evaluate with context
132
- const enabled = await checkoutFlag.get({ user: { plan: "enterprise" } });
133
- console.log("checkout-v2:", enabled);
188
+ // Manual re-fetch
189
+ await client.flags.refresh();
134
190
 
135
- client.close();
191
+ // Cache stats (cacheHits / cacheMisses)
192
+ const stats = client.flags.stats();
136
193
  ```
137
194
 
138
195
  ### Management (CRUD)
139
196
 
140
197
  ```typescript
141
- // Create flags
142
- const boolFlag = client.flags.management.newBooleanFlag("checkout-v2", {
198
+ import { Rule, Op } from "@smplkit/sdk";
199
+
200
+ const flag = client.manage.flags.newBooleanFlag("checkout-v2", {
143
201
  default: false,
144
202
  description: "Controls rollout of the new checkout experience.",
145
203
  });
146
204
 
147
- // Add targeting rules
148
- boolFlag.addRule(
149
- new Rule("Enable for enterprise users")
150
- .environment("production")
151
- .when("user.plan", "==", "enterprise")
152
- .serve(true)
153
- .build(),
205
+ // Targeting rule — `environment` is required on the Rule constructor
206
+ flag.addRule(
207
+ new Rule("Enable for enterprise users", { environment: "production" })
208
+ .when("user.plan", Op.EQ, "enterprise")
209
+ .when("account.region", Op.EQ, "us")
210
+ .serve(true),
154
211
  );
155
212
 
156
- // Configure environments
157
- boolFlag.setEnvironmentEnabled("production", true);
158
- boolFlag.setEnvironmentDefault("production", false);
213
+ // Per-environment defaults and kill-switch
214
+ flag.setDefault(false, { environment: "production" });
215
+ flag.disableRules({ environment: "staging" }); // kill switch
216
+ flag.enableRules({ environment: "production" });
159
217
 
160
- await boolFlag.save();
218
+ await flag.save();
161
219
 
162
- // Other factory methods
163
- const strFlag = client.flags.management.newStringFlag("banner-color", {
220
+ // Other typed factories
221
+ const banner = client.manage.flags.newStringFlag("banner-color", {
164
222
  default: "red",
165
- values: [{ name: "Red", value: "red" }, { name: "Blue", value: "blue" }],
166
- });
167
- const numFlag = client.flags.management.newNumberFlag("max-retries", { default: 3 });
168
- const jsonFlag = client.flags.management.newJsonFlag("ui-theme", {
169
- default: { mode: "light" },
223
+ values: [
224
+ { name: "Red", value: "red" },
225
+ { name: "Blue", value: "blue" },
226
+ ],
170
227
  });
228
+ const retries = client.manage.flags.newNumberFlag("max-retries", { default: 3 });
229
+ const theme = client.manage.flags.newJsonFlag("ui-theme", { default: { mode: "light" } });
230
+
231
+ // CRUD
232
+ const all = await client.manage.flags.list();
233
+ const fetched = await client.manage.flags.get("checkout-v2");
234
+ await client.manage.flags.delete("checkout-v2");
235
+ ```
171
236
 
172
- // List / get / delete
173
- const flags = await client.flags.management.list();
174
- const flag = await client.flags.management.get("checkout-v2");
175
- await client.flags.management.delete("checkout-v2");
237
+ ### Contexts
238
+
239
+ Bulk-register context entities so the platform knows about them (used in the targeting UI, dashboards, etc.):
240
+
241
+ ```typescript
242
+ await client.manage.contexts.register([
243
+ new Context("user", "alice@acme.com", { plan: "enterprise" }),
244
+ new Context("account", "1234", { region: "us" }),
245
+ ]);
246
+ await client.manage.contexts.flush(); // or pass `{ flush: true }` to register
176
247
  ```
177
248
 
178
249
  ## Logging
179
250
 
180
- ### Runtime (live log level management)
251
+ ### Runtime live log level management
252
+
253
+ `install()` auto-discovers winston and pino loggers, hooks new-logger creation, applies server-managed levels, and subscribes to live updates over the shared WebSocket.
181
254
 
182
255
  ```typescript
256
+ import { SmplClient, LogLevel } from "@smplkit/sdk";
257
+
183
258
  const client = new SmplClient({ environment: "production", service: "my-service" });
259
+ await client.logging.install();
184
260
 
185
- // Register an adapter for your logging library
186
- client.logging.registerAdapter(myAdapter);
261
+ client.logging.onChange((event) => {
262
+ console.log(`${event.id}: ${event.level} (source=${event.source})`);
263
+ });
187
264
 
188
- // Start the logging runtime (connects and fetches log levels)
189
- await client.logging.start();
265
+ // Force a manual re-sync (e.g. after suspecting drift)
266
+ await client.logging.refresh();
267
+ ```
190
268
 
191
- client.logging.onChange((loggers) => {
192
- console.log("Log levels updated:", loggers.map((l) => `${l.id}=${l.level}`));
193
- });
269
+ **Adapter coverage.** Winston named loggers (`winston.loggers.*`) and the default winston logger are auto-discovered at install time. Pino has no global registry, so only loggers created through `pino()` / `logger.child()` after `install()` runs are tracked — pre-existing pino loggers must be recreated or explicitly registered via `client.manage.loggers.register([...])`. There is no console adapter; use a supported framework (winston or pino) to bring loggers under management.
270
+
271
+ You can also register a custom adapter:
272
+
273
+ ```typescript
274
+ client.logging.registerAdapter(myAdapter); // must implement LoggingAdapter
275
+ await client.logging.install();
194
276
  ```
195
277
 
196
278
  ### Management (CRUD)
197
279
 
280
+ Loggers and log groups have separate namespaces:
281
+
198
282
  ```typescript
199
- // Create a logger
200
- const logger = client.logging.management.new("sqlalchemy.engine", { managed: true });
201
- logger.setLevel(LogLevel.WARN);
202
- logger.setEnvironmentLevel("production", LogLevel.ERROR);
203
- await logger.save();
204
-
205
- // Create a log group
206
- const group = client.logging.management.newGroup("sql", { name: "SQL Loggers" });
283
+ // Loggers
284
+ const sql = client.manage.loggers.new("sqlalchemy.engine", { managed: true });
285
+ sql.setLevel(LogLevel.WARN);
286
+ sql.setLevel(LogLevel.ERROR, { environment: "production" });
287
+ await sql.save();
288
+
289
+ const all = await client.manage.loggers.list();
290
+ const fetched = await client.manage.loggers.get("sqlalchemy.engine");
291
+ await client.manage.loggers.delete("sqlalchemy.engine");
292
+
293
+ // Log groups (a way to bulk-set levels across many loggers)
294
+ const group = client.manage.logGroups.new("sql", { name: "SQL Loggers" });
207
295
  group.setLevel(LogLevel.WARN);
208
296
  await group.save();
209
297
 
210
- // Assign logger to group
211
- logger.group = group.id;
212
- await logger.save();
298
+ await client.manage.logGroups.list();
299
+ await client.manage.logGroups.get("sql");
300
+ await client.manage.logGroups.delete("sql");
301
+ ```
302
+
303
+ ## Standalone management client
213
304
 
214
- // List / get / delete
215
- const loggers = await client.logging.management.list();
216
- const fetched = await client.logging.management.get("Sqlalchemy.Engine");
217
- await client.logging.management.delete("Sqlalchemy.Engine");
305
+ For setup scripts, CI tooling, and admin utilities you don't need the runtime plane (no WebSocket, no metrics thread, no logger discovery). Construct `SmplManagementClient` directly:
218
306
 
219
- const groups = await client.logging.management.listGroups();
220
- const fetchedGroup = await client.logging.management.getGroup(group.id);
221
- await client.logging.management.deleteGroup(group.id);
307
+ ```typescript
308
+ import { SmplManagementClient } from "@smplkit/sdk";
309
+
310
+ const manage = new SmplManagementClient(); // resolves apiKey from env / ~/.smplkit
311
+ await manage.environments.list();
312
+ await manage.config.new("my-service", { name: "My Service" }).save();
313
+ await manage.close(); // flushes any buffered context/flag/logger registrations
222
314
  ```
223
315
 
316
+ The runtime `client.manage` and a standalone `SmplManagementClient` expose the same surface: `config`, `flags`, `loggers`, `logGroups`, `contexts`, `contextTypes`, `environments`, `accountSettings`.
317
+
224
318
  ## Error Handling
225
319
 
226
- All SDK errors extend `SmplError`:
320
+ All SDK errors extend `SmplError` (also re-exported as `SmplkitError` for callers that prefer the longer prefix).
227
321
 
228
322
  ```typescript
229
323
  import { SmplError, SmplNotFoundError } from "@smplkit/sdk";
230
324
 
231
325
  try {
232
- const flag = await client.flags.management.get("nonexistent");
326
+ await client.manage.flags.get("nonexistent");
233
327
  } catch (err) {
234
328
  if (err instanceof SmplNotFoundError) {
235
329
  console.log("Not found:", err.message);
236
330
  } else if (err instanceof SmplError) {
237
331
  console.log("SDK error:", err.statusCode, err.responseBody);
332
+ console.log("Structured details:", err.errors);
238
333
  }
239
334
  }
240
335
  ```
241
336
 
242
- | Error | Cause |
243
- |------------------------|------------------------------|
244
- | `SmplNotFoundError` | HTTP 404 — resource not found |
245
- | `SmplConflictError` | HTTP 409 — conflict |
246
- | `SmplValidationError` | HTTP 422 — validation error |
247
- | `SmplTimeoutError` | Request timed out |
248
- | `SmplConnectionError` | Network connectivity issue |
249
- | `SmplError` | Any other SDK error |
337
+ | Error | Cause |
338
+ | --------------------- | ---------------------------------- |
339
+ | `SmplNotFoundError` | HTTP 404 — resource not found |
340
+ | `SmplConflictError` | HTTP 409 — conflict |
341
+ | `SmplValidationError` | HTTP 422 — validation error |
342
+ | `SmplTimeoutError` | Request timed out |
343
+ | `SmplConnectionError` | Network connectivity issue |
344
+ | `SmplError` | Base class for any other SDK error |
250
345
 
251
346
  ## Debug Logging
252
347
 
253
- Set `SMPLKIT_DEBUG=1` to enable verbose diagnostic output to stderr. This is useful for troubleshooting real-time level changes, WebSocket connectivity, and SDK initialization. Debug output bypasses the managed logging framework and writes directly to stderr.
348
+ Set `SMPLKIT_DEBUG` to enable verbose diagnostic output to stderr useful when troubleshooting WebSocket connectivity, level resolution, or initialization.
254
349
 
255
350
  ```bash
256
351
  SMPLKIT_DEBUG=1 node my-app.js
257
352
  ```
258
353
 
259
- Accepted values: `1`, `true`, `yes` (case-insensitive). Any other value (or unset) disables debug output.
354
+ Accepted values: `1`, `true`, `yes` (case-insensitive). Any other value (or unset) disables debug output. You can also enable it programmatically via `new SmplClient({ debug: true })`.
260
355
 
261
356
  ## Documentation
262
357
 
package/dist/index.cjs CHANGED
@@ -19102,13 +19102,23 @@ var LogGroup = class {
19102
19102
  if (this._client === null) {
19103
19103
  throw new Error("LogGroup was constructed without a client; cannot save");
19104
19104
  }
19105
- if (!this._client._saveGroup) {
19106
- throw new Error(
19107
- "LogGroup models obtained from the runtime LoggingClient cannot be saved. Use mgmt.logGroups.new(...) (or client.manage.logGroups.*) to author groups."
19108
- );
19105
+ if (this.createdAt === null) {
19106
+ if (!this._client._createGroup) {
19107
+ throw new Error(
19108
+ "LogGroup models obtained from the runtime LoggingClient cannot be saved. Use mgmt.logGroups.new(...) (or client.manage.logGroups.*) to author groups."
19109
+ );
19110
+ }
19111
+ const created = await this._client._createGroup(this);
19112
+ this._apply(created);
19113
+ } else {
19114
+ if (!this._client._updateGroup) {
19115
+ throw new Error(
19116
+ "LogGroup models obtained from the runtime LoggingClient cannot be saved. Use mgmt.logGroups.new(...) (or client.manage.logGroups.*) to author groups."
19117
+ );
19118
+ }
19119
+ const updated = await this._client._updateGroup(this);
19120
+ this._apply(updated);
19109
19121
  }
19110
- const saved = await this._client._saveGroup(this);
19111
- this._apply(saved);
19112
19122
  }
19113
19123
  async delete() {
19114
19124
  if (this._client === null || this.id === null) {
@@ -19424,9 +19434,28 @@ var LogGroupsClient = class {
19424
19434
  wrapFetchError3(err);
19425
19435
  }
19426
19436
  }
19427
- /** @internal — called by `LogGroup.save()`. PUT /log_groups/{id} is upsert. */
19428
- async _saveGroup(group) {
19429
- if (group.id === null) throw new Error("Cannot save a LogGroup with no id");
19437
+ /**
19438
+ * @internal — called by `LogGroup.save()` for new groups.
19439
+ * POST /api/v1/log_groups creates a new log group. The {id} PUT endpoint
19440
+ * is update-only on the server and returns 404 for non-existent ids,
19441
+ * so create and update have to dispatch to different endpoints.
19442
+ */
19443
+ async _createGroup(group) {
19444
+ const body = groupToBody(group);
19445
+ let data;
19446
+ try {
19447
+ const result = await this._http.POST("/api/v1/log_groups", { body });
19448
+ if (!result.response.ok) await checkError3(result.response);
19449
+ data = result.data;
19450
+ } catch (err) {
19451
+ wrapFetchError3(err);
19452
+ }
19453
+ if (!data || !data.data) throw new SmplValidationError("Failed to create log group");
19454
+ return resourceToLogGroup(data.data, this);
19455
+ }
19456
+ /** @internal — called by `LogGroup.save()` for existing groups. */
19457
+ async _updateGroup(group) {
19458
+ if (group.id === null) throw new Error("Cannot update a LogGroup with no id");
19430
19459
  const body = groupToBody(group);
19431
19460
  let data;
19432
19461
  try {
@@ -19439,7 +19468,9 @@ var LogGroupsClient = class {
19439
19468
  } catch (err) {
19440
19469
  wrapFetchError3(err);
19441
19470
  }
19442
- if (!data || !data.data) throw new SmplValidationError("Failed to save log group");
19471
+ if (!data || !data.data) {
19472
+ throw new SmplValidationError(`Failed to update log group ${group.id}`);
19473
+ }
19443
19474
  return resourceToLogGroup(data.data, this);
19444
19475
  }
19445
19476
  };