@smplkit/sdk 1.0.1 → 1.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/index.cjs CHANGED
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
6
11
  var __export = (target, all) => {
7
12
  for (var name in all)
8
13
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -15,12 +20,349 @@ var __copyProps = (to, from, except, desc) => {
15
20
  }
16
21
  return to;
17
22
  };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
18
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
32
 
33
+ // src/config/resolve.ts
34
+ function deepMerge(base, override) {
35
+ const result = { ...base };
36
+ for (const [key, value] of Object.entries(override)) {
37
+ if (key in result && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) && typeof value === "object" && value !== null && !Array.isArray(value)) {
38
+ result[key] = deepMerge(
39
+ result[key],
40
+ value
41
+ );
42
+ } else {
43
+ result[key] = value;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+ function resolveChain(chain, environment) {
49
+ let accumulated = {};
50
+ for (let i = chain.length - 1; i >= 0; i--) {
51
+ const config = chain[i];
52
+ const baseValues = config.values ?? {};
53
+ const envEntry = (config.environments ?? {})[environment];
54
+ const envValues = envEntry !== null && envEntry !== void 0 && typeof envEntry === "object" && !Array.isArray(envEntry) ? envEntry.values ?? {} : {};
55
+ const configResolved = deepMerge(baseValues, envValues);
56
+ accumulated = deepMerge(accumulated, configResolved);
57
+ }
58
+ return accumulated;
59
+ }
60
+ var init_resolve = __esm({
61
+ "src/config/resolve.ts"() {
62
+ "use strict";
63
+ }
64
+ });
65
+
66
+ // src/config/runtime.ts
67
+ var runtime_exports = {};
68
+ __export(runtime_exports, {
69
+ ConfigRuntime: () => ConfigRuntime
70
+ });
71
+ var import_ws, BACKOFF_MS, ConfigRuntime;
72
+ var init_runtime = __esm({
73
+ "src/config/runtime.ts"() {
74
+ "use strict";
75
+ import_ws = __toESM(require("ws"), 1);
76
+ init_resolve();
77
+ BACKOFF_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
78
+ ConfigRuntime = class {
79
+ _cache;
80
+ _chain;
81
+ _fetchCount;
82
+ _lastFetchAt;
83
+ _closed = false;
84
+ _wsStatus = "disconnected";
85
+ _ws = null;
86
+ _reconnectTimer = null;
87
+ _backoffIndex = 0;
88
+ _listeners = [];
89
+ _configId;
90
+ _environment;
91
+ _apiKey;
92
+ _baseUrl;
93
+ _fetchChain;
94
+ /** @internal */
95
+ constructor(options) {
96
+ this._configId = options.configId;
97
+ this._environment = options.environment;
98
+ this._apiKey = options.apiKey;
99
+ this._baseUrl = options.baseUrl;
100
+ this._fetchChain = options.fetchChain;
101
+ this._chain = options.chain;
102
+ this._cache = resolveChain(options.chain, options.environment);
103
+ this._fetchCount = options.chain.length;
104
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
105
+ this._connectWebSocket();
106
+ }
107
+ // ---- Value access (synchronous, local cache) ----
108
+ /**
109
+ * Return the resolved value for `key`, or `defaultValue` if absent.
110
+ *
111
+ * @param key - The config key to look up.
112
+ * @param defaultValue - Returned when the key is not present (default: null).
113
+ */
114
+ get(key, defaultValue = null) {
115
+ return key in this._cache ? this._cache[key] : defaultValue;
116
+ }
117
+ /**
118
+ * Return the value as a string, or `defaultValue` if absent or not a string.
119
+ */
120
+ getString(key, defaultValue = null) {
121
+ const value = this._cache[key];
122
+ return typeof value === "string" ? value : defaultValue;
123
+ }
124
+ /**
125
+ * Return the value as a number, or `defaultValue` if absent or not a number.
126
+ */
127
+ getNumber(key, defaultValue = null) {
128
+ const value = this._cache[key];
129
+ return typeof value === "number" ? value : defaultValue;
130
+ }
131
+ /**
132
+ * Return the value as a boolean, or `defaultValue` if absent or not a boolean.
133
+ */
134
+ getBool(key, defaultValue = null) {
135
+ const value = this._cache[key];
136
+ return typeof value === "boolean" ? value : defaultValue;
137
+ }
138
+ /**
139
+ * Return whether `key` is present in the resolved configuration.
140
+ */
141
+ exists(key) {
142
+ return key in this._cache;
143
+ }
144
+ /**
145
+ * Return a shallow copy of the full resolved configuration.
146
+ */
147
+ getAll() {
148
+ return { ...this._cache };
149
+ }
150
+ // ---- Change listeners ----
151
+ /**
152
+ * Register a listener that fires when a config value changes.
153
+ *
154
+ * @param callback - Called with a {@link ConfigChangeEvent} on each change.
155
+ * @param options.key - If provided, the listener fires only for this key.
156
+ * If omitted, the listener fires for all changes.
157
+ */
158
+ onChange(callback, options) {
159
+ this._listeners.push({
160
+ callback,
161
+ key: options?.key ?? null
162
+ });
163
+ }
164
+ // ---- Diagnostics ----
165
+ /**
166
+ * Return diagnostic statistics for this runtime.
167
+ */
168
+ stats() {
169
+ return {
170
+ fetchCount: this._fetchCount,
171
+ lastFetchAt: this._lastFetchAt
172
+ };
173
+ }
174
+ /**
175
+ * Return the current WebSocket connection status.
176
+ */
177
+ connectionStatus() {
178
+ return this._wsStatus;
179
+ }
180
+ // ---- Lifecycle ----
181
+ /**
182
+ * Force a manual refresh of the cached configuration.
183
+ *
184
+ * Re-fetches the full config chain via HTTP, re-resolves values, updates
185
+ * the local cache, and fires listeners for any detected changes.
186
+ *
187
+ * @throws {Error} If no `fetchChain` function was provided on construction.
188
+ */
189
+ async refresh() {
190
+ if (!this._fetchChain) {
191
+ throw new Error("No fetchChain function provided; cannot refresh.");
192
+ }
193
+ const newChain = await this._fetchChain();
194
+ const oldCache = this._cache;
195
+ this._chain = newChain;
196
+ this._cache = resolveChain(newChain, this._environment);
197
+ this._fetchCount += newChain.length;
198
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
199
+ this._diffAndFire(oldCache, this._cache, "manual");
200
+ }
201
+ /**
202
+ * Close the runtime connection.
203
+ *
204
+ * Shuts down the WebSocket and cancels any pending reconnect timer.
205
+ * Safe to call multiple times.
206
+ */
207
+ async close() {
208
+ this._closed = true;
209
+ this._wsStatus = "disconnected";
210
+ if (this._reconnectTimer !== null) {
211
+ clearTimeout(this._reconnectTimer);
212
+ this._reconnectTimer = null;
213
+ }
214
+ if (this._ws !== null) {
215
+ this._ws.close();
216
+ this._ws = null;
217
+ }
218
+ }
219
+ /**
220
+ * Async dispose support for `await using` (TypeScript 5.2+).
221
+ */
222
+ async [Symbol.asyncDispose]() {
223
+ await this.close();
224
+ }
225
+ // ---- WebSocket internals ----
226
+ _buildWsUrl() {
227
+ let url = this._baseUrl;
228
+ if (url.startsWith("https://")) {
229
+ url = "wss://" + url.slice("https://".length);
230
+ } else if (url.startsWith("http://")) {
231
+ url = "ws://" + url.slice("http://".length);
232
+ } else {
233
+ url = "wss://" + url;
234
+ }
235
+ url = url.replace(/\/$/, "");
236
+ return `${url}/api/ws/v1/configs?api_key=${this._apiKey}`;
237
+ }
238
+ _connectWebSocket() {
239
+ if (this._closed) return;
240
+ this._wsStatus = "connecting";
241
+ const wsUrl = this._buildWsUrl();
242
+ try {
243
+ const ws = new import_ws.default(wsUrl);
244
+ this._ws = ws;
245
+ ws.on("open", () => {
246
+ if (this._closed) {
247
+ ws.close();
248
+ return;
249
+ }
250
+ this._backoffIndex = 0;
251
+ this._wsStatus = "connected";
252
+ ws.send(
253
+ JSON.stringify({
254
+ type: "subscribe",
255
+ config_id: this._configId,
256
+ environment: this._environment
257
+ })
258
+ );
259
+ });
260
+ ws.on("message", (data) => {
261
+ try {
262
+ const msg = JSON.parse(String(data));
263
+ this._handleMessage(msg);
264
+ } catch {
265
+ }
266
+ });
267
+ ws.on("close", () => {
268
+ if (!this._closed) {
269
+ this._wsStatus = "disconnected";
270
+ this._scheduleReconnect();
271
+ }
272
+ });
273
+ ws.on("error", () => {
274
+ });
275
+ } catch {
276
+ if (!this._closed) {
277
+ this._scheduleReconnect();
278
+ }
279
+ }
280
+ }
281
+ _scheduleReconnect() {
282
+ if (this._closed) return;
283
+ const delay = BACKOFF_MS[Math.min(this._backoffIndex, BACKOFF_MS.length - 1)];
284
+ this._backoffIndex++;
285
+ this._wsStatus = "connecting";
286
+ this._reconnectTimer = setTimeout(() => {
287
+ this._reconnectTimer = null;
288
+ if (this._fetchChain) {
289
+ this._fetchChain().then((newChain) => {
290
+ const oldCache = this._cache;
291
+ this._chain = newChain;
292
+ this._cache = resolveChain(newChain, this._environment);
293
+ this._fetchCount += newChain.length;
294
+ this._lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
295
+ this._diffAndFire(oldCache, this._cache, "manual");
296
+ }).catch(() => {
297
+ }).finally(() => {
298
+ this._connectWebSocket();
299
+ });
300
+ } else {
301
+ this._connectWebSocket();
302
+ }
303
+ }, delay);
304
+ }
305
+ _handleMessage(msg) {
306
+ if (msg.type === "config_changed") {
307
+ this._applyChanges(msg.config_id, msg.changes);
308
+ } else if (msg.type === "config_deleted") {
309
+ this._closed = true;
310
+ void this.close();
311
+ }
312
+ }
313
+ _applyChanges(configId, changes) {
314
+ const chainEntry = this._chain.find((c) => c.id === configId);
315
+ if (!chainEntry) return;
316
+ for (const change of changes) {
317
+ const { key, new_value } = change;
318
+ const envEntry = chainEntry.environments[this._environment] !== void 0 && chainEntry.environments[this._environment] !== null ? chainEntry.environments[this._environment] : null;
319
+ const envValues = envEntry !== null && typeof envEntry === "object" ? envEntry.values ?? {} : null;
320
+ if (new_value === null || new_value === void 0) {
321
+ delete chainEntry.values[key];
322
+ if (envValues) delete envValues[key];
323
+ } else if (envValues && key in envValues) {
324
+ envValues[key] = new_value;
325
+ } else if (key in chainEntry.values) {
326
+ chainEntry.values[key] = new_value;
327
+ } else {
328
+ chainEntry.values[key] = new_value;
329
+ }
330
+ }
331
+ const oldCache = this._cache;
332
+ this._cache = resolveChain(this._chain, this._environment);
333
+ this._diffAndFire(oldCache, this._cache, "websocket");
334
+ }
335
+ _diffAndFire(oldCache, newCache, source) {
336
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldCache), ...Object.keys(newCache)]);
337
+ for (const key of allKeys) {
338
+ const oldVal = key in oldCache ? oldCache[key] : null;
339
+ const newVal = key in newCache ? newCache[key] : null;
340
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
341
+ const event = { key, oldValue: oldVal, newValue: newVal, source };
342
+ this._fireListeners(event);
343
+ }
344
+ }
345
+ }
346
+ _fireListeners(event) {
347
+ for (const listener of this._listeners) {
348
+ if (listener.key === null || listener.key === event.key) {
349
+ try {
350
+ listener.callback(event);
351
+ } catch {
352
+ }
353
+ }
354
+ }
355
+ }
356
+ };
357
+ }
358
+ });
359
+
20
360
  // src/index.ts
21
361
  var index_exports = {};
22
362
  __export(index_exports, {
363
+ Config: () => Config,
23
364
  ConfigClient: () => ConfigClient,
365
+ ConfigRuntime: () => ConfigRuntime,
24
366
  SmplConflictError: () => SmplConflictError,
25
367
  SmplConnectionError: () => SmplConnectionError,
26
368
  SmplError: () => SmplError,
@@ -31,6 +373,9 @@ __export(index_exports, {
31
373
  });
32
374
  module.exports = __toCommonJS(index_exports);
33
375
 
376
+ // src/config/client.ts
377
+ var import_openapi_fetch = __toESM(require("openapi-fetch"), 1);
378
+
34
379
  // src/errors.ts
35
380
  var SmplError = class extends Error {
36
381
  /** The HTTP status code, if the error originated from an HTTP response. */
@@ -81,277 +426,443 @@ var SmplValidationError = class extends SmplError {
81
426
  }
82
427
  };
83
428
 
84
- // src/config/client.ts
85
- var CONFIGS_PATH = "/api/v1/configs";
86
- var ConfigClient = class {
87
- /** @internal */
88
- transport;
429
+ // src/config/types.ts
430
+ var Config = class {
431
+ /** UUID of the config. */
432
+ id;
433
+ /** Human-readable key (e.g. `"user_service"`). */
434
+ key;
435
+ /** Display name. */
436
+ name;
437
+ /** Optional description. */
438
+ description;
439
+ /** Parent config UUID, or null if this is a root config. */
440
+ parent;
441
+ /** Base key-value pairs. */
442
+ values;
443
+ /**
444
+ * Per-environment overrides.
445
+ * Stored as `{ env_name: { values: { key: value } } }` to match the
446
+ * server's format.
447
+ */
448
+ environments;
449
+ /** When the config was created, or null if unavailable. */
450
+ createdAt;
451
+ /** When the config was last updated, or null if unavailable. */
452
+ updatedAt;
453
+ /**
454
+ * Internal reference to the parent client.
455
+ * @internal
456
+ */
457
+ _client;
89
458
  /** @internal */
90
- constructor(transport) {
91
- this.transport = transport;
459
+ constructor(client, fields) {
460
+ this._client = client;
461
+ this.id = fields.id;
462
+ this.key = fields.key;
463
+ this.name = fields.name;
464
+ this.description = fields.description;
465
+ this.parent = fields.parent;
466
+ this.values = fields.values;
467
+ this.environments = fields.environments;
468
+ this.createdAt = fields.createdAt;
469
+ this.updatedAt = fields.updatedAt;
92
470
  }
93
471
  /**
94
- * Fetch a single config by key or UUID.
472
+ * Update this config's attributes on the server.
95
473
  *
96
- * Exactly one of `key` or `id` must be provided.
474
+ * Builds the request from current attribute values, overriding with any
475
+ * provided options. Updates local attributes in place on success.
97
476
  *
98
- * @param options - Lookup options.
99
- * @returns The matching config.
100
- * @throws {SmplNotFoundError} If no matching config exists.
101
- * @throws {Error} If neither or both of `key` and `id` are provided.
477
+ * @param options.name - New display name.
478
+ * @param options.description - New description (pass empty string to clear).
479
+ * @param options.values - New base values (replaces entirely).
480
+ * @param options.environments - New environments dict (replaces entirely).
102
481
  */
103
- async get(options) {
104
- const { key, id } = options;
105
- if (key === void 0 === (id === void 0)) {
106
- throw new Error("Exactly one of 'key' or 'id' must be provided.");
107
- }
108
- if (id !== void 0) {
109
- return this.getById(id);
110
- }
111
- return this.getByKey(key);
482
+ async update(options) {
483
+ const updated = await this._client._updateConfig({
484
+ configId: this.id,
485
+ name: options.name ?? this.name,
486
+ key: this.key,
487
+ description: options.description !== void 0 ? options.description : this.description,
488
+ parent: this.parent,
489
+ values: options.values ?? this.values,
490
+ environments: options.environments ?? this.environments
491
+ });
492
+ this.name = updated.name;
493
+ this.description = updated.description;
494
+ this.values = updated.values;
495
+ this.environments = updated.environments;
496
+ this.updatedAt = updated.updatedAt;
112
497
  }
113
498
  /**
114
- * List all configs for the account.
499
+ * Replace base or environment-specific values.
500
+ *
501
+ * When `environment` is provided, replaces that environment's `values`
502
+ * sub-dict (other environments are preserved). When omitted, replaces
503
+ * the base `values`.
115
504
  *
116
- * @returns An array of config objects.
505
+ * @param values - The complete set of values to set.
506
+ * @param environment - Target environment, or omit for base values.
117
507
  */
118
- async list() {
119
- const response = await this.transport.get(CONFIGS_PATH);
120
- const resources = response.data;
121
- return resources.map((r) => this.resourceToModel(r));
508
+ async setValues(values, environment) {
509
+ let newValues;
510
+ let newEnvs;
511
+ if (environment === void 0) {
512
+ newValues = values;
513
+ newEnvs = this.environments;
514
+ } else {
515
+ newValues = this.values;
516
+ const existingEntry = typeof this.environments[environment] === "object" && this.environments[environment] !== null ? { ...this.environments[environment] } : {};
517
+ existingEntry.values = values;
518
+ newEnvs = { ...this.environments, [environment]: existingEntry };
519
+ }
520
+ const updated = await this._client._updateConfig({
521
+ configId: this.id,
522
+ name: this.name,
523
+ key: this.key,
524
+ description: this.description,
525
+ parent: this.parent,
526
+ values: newValues,
527
+ environments: newEnvs
528
+ });
529
+ this.values = updated.values;
530
+ this.environments = updated.environments;
531
+ this.updatedAt = updated.updatedAt;
122
532
  }
123
533
  /**
124
- * Create a new config.
534
+ * Set a single key within base or environment-specific values.
125
535
  *
126
- * @param options - Config creation options.
127
- * @returns The created config.
128
- * @throws {SmplValidationError} If the server rejects the request.
536
+ * Merges the key into existing values rather than replacing all values.
537
+ *
538
+ * @param key - The config key to set.
539
+ * @param value - The value to assign.
540
+ * @param environment - Target environment, or omit for base values.
129
541
  */
130
- async create(options) {
131
- const body = this.buildRequestBody(options);
132
- const response = await this.transport.post(CONFIGS_PATH, body);
133
- if (!response.data) {
134
- throw new SmplValidationError("Failed to create config");
542
+ async setValue(key, value, environment) {
543
+ if (environment === void 0) {
544
+ const merged = { ...this.values, [key]: value };
545
+ await this.setValues(merged);
546
+ } else {
547
+ const envEntry = typeof this.environments[environment] === "object" && this.environments[environment] !== null ? this.environments[environment] : {};
548
+ const existing = {
549
+ ...typeof envEntry.values === "object" && envEntry.values !== null ? envEntry.values : {}
550
+ };
551
+ existing[key] = value;
552
+ await this.setValues(existing, environment);
135
553
  }
136
- return this.resourceToModel(response.data);
137
554
  }
138
555
  /**
139
- * Delete a config by UUID.
556
+ * Connect to this config for runtime value resolution.
140
557
  *
141
- * @param configId - The UUID of the config to delete.
142
- * @throws {SmplNotFoundError} If the config does not exist.
143
- * @throws {SmplConflictError} If the config has children.
558
+ * Eagerly fetches this config and its full parent chain, resolves values
559
+ * for the given environment via deep merge, and returns a
560
+ * {@link ConfigRuntime} with a fully populated local cache.
561
+ *
562
+ * A background WebSocket connection is started for real-time updates.
563
+ * If the WebSocket fails to connect, the runtime operates in cache-only
564
+ * mode and reconnects automatically.
565
+ *
566
+ * Supports both `await` and `await using` (TypeScript 5.2+)::
567
+ *
568
+ * ```typescript
569
+ * // Simple await
570
+ * const runtime = await config.connect("production");
571
+ * try { ... } finally { await runtime.close(); }
572
+ *
573
+ * // await using (auto-close)
574
+ * await using runtime = await config.connect("production");
575
+ * ```
576
+ *
577
+ * @param environment - The environment to resolve for (e.g. `"production"`).
578
+ * @param options.timeout - Milliseconds to wait for the initial fetch.
144
579
  */
145
- async delete(configId) {
146
- await this.transport.delete(`${CONFIGS_PATH}/${configId}`);
147
- }
148
- /** Fetch a config by UUID. */
149
- async getById(configId) {
150
- const response = await this.transport.get(`${CONFIGS_PATH}/${configId}`);
151
- if (!response.data) {
152
- throw new SmplNotFoundError(`Config ${configId} not found`);
153
- }
154
- return this.resourceToModel(response.data);
155
- }
156
- /** Fetch a config by key using the list endpoint with a filter. */
157
- async getByKey(key) {
158
- const response = await this.transport.get(CONFIGS_PATH, { "filter[key]": key });
159
- const resources = response.data;
160
- if (!resources || resources.length === 0) {
161
- throw new SmplNotFoundError(`Config with key '${key}' not found`);
162
- }
163
- return this.resourceToModel(resources[0]);
580
+ async connect(environment, options) {
581
+ const { ConfigRuntime: ConfigRuntime2 } = await Promise.resolve().then(() => (init_runtime(), runtime_exports));
582
+ const timeout = options?.timeout ?? 3e4;
583
+ const chain = await this._buildChain(timeout);
584
+ return new ConfigRuntime2({
585
+ configKey: this.key,
586
+ configId: this.id,
587
+ environment,
588
+ chain,
589
+ apiKey: this._client._apiKey,
590
+ baseUrl: this._client._baseUrl,
591
+ fetchChain: () => this._buildChain(timeout)
592
+ });
164
593
  }
165
594
  /**
166
- * Convert a JSON:API resource to a Config domain model.
595
+ * Walk the parent chain and return config data objects, child-to-root.
167
596
  * @internal
168
597
  */
169
- resourceToModel(resource) {
170
- const attrs = resource.attributes;
171
- return {
172
- id: resource.id,
173
- key: attrs.key ?? "",
174
- name: attrs.name,
175
- description: attrs.description ?? null,
176
- parent: attrs.parent ?? null,
177
- values: attrs.values ?? {},
178
- environments: attrs.environments ?? {},
179
- createdAt: attrs.created_at ? new Date(attrs.created_at) : null,
180
- updatedAt: attrs.updated_at ? new Date(attrs.updated_at) : null
181
- };
598
+ async _buildChain(_timeout) {
599
+ const chain = [{ id: this.id, values: this.values, environments: this.environments }];
600
+ let parentId = this.parent;
601
+ while (parentId !== null) {
602
+ const parentConfig = await this._client.get({ id: parentId });
603
+ chain.push({
604
+ id: parentConfig.id,
605
+ values: parentConfig.values,
606
+ environments: parentConfig.environments
607
+ });
608
+ parentId = parentConfig.parent;
609
+ }
610
+ return chain;
182
611
  }
183
- /** Build a JSON:API request body for create operations. */
184
- buildRequestBody(options) {
185
- const attributes = {
186
- name: options.name
187
- };
188
- if (options.key !== void 0) attributes.key = options.key;
189
- if (options.description !== void 0) attributes.description = options.description;
190
- if (options.parent !== void 0) attributes.parent = options.parent;
191
- if (options.values !== void 0) attributes.values = options.values;
192
- return {
193
- data: {
194
- type: "config",
195
- attributes
196
- }
197
- };
612
+ toString() {
613
+ return `Config(id=${this.id}, key=${this.key}, name=${this.name})`;
198
614
  }
199
615
  };
200
616
 
201
- // src/auth.ts
202
- function buildAuthHeader(apiKey) {
203
- return `Bearer ${apiKey}`;
617
+ // src/config/client.ts
618
+ var BASE_URL = "https://config.smplkit.com";
619
+ function resourceToConfig(resource, client) {
620
+ const attrs = resource.attributes;
621
+ return new Config(client, {
622
+ id: resource.id ?? "",
623
+ key: attrs.key ?? "",
624
+ name: attrs.name,
625
+ description: attrs.description ?? null,
626
+ parent: attrs.parent ?? null,
627
+ values: attrs.values ?? {},
628
+ environments: attrs.environments ?? {},
629
+ createdAt: attrs.created_at ? new Date(attrs.created_at) : null,
630
+ updatedAt: attrs.updated_at ? new Date(attrs.updated_at) : null
631
+ });
204
632
  }
205
-
206
- // src/transport.ts
207
- var SDK_VERSION = "0.0.0";
208
- var DEFAULT_TIMEOUT_MS = 3e4;
209
- var Transport = class {
210
- apiKey;
211
- baseUrl;
212
- timeout;
213
- constructor(options) {
214
- this.apiKey = options.apiKey;
215
- this.baseUrl = options.baseUrl.replace(/\/+$/, "");
216
- this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
633
+ async function checkError(response, context) {
634
+ const body = await response.text().catch(() => "");
635
+ switch (response.status) {
636
+ case 404:
637
+ throw new SmplNotFoundError(body || context, 404, body);
638
+ case 409:
639
+ throw new SmplConflictError(body || context, 409, body);
640
+ case 422:
641
+ throw new SmplValidationError(body || context, 422, body);
642
+ default:
643
+ throw new SmplError(`HTTP ${response.status}: ${body}`, response.status, body);
644
+ }
645
+ }
646
+ function wrapFetchError(err) {
647
+ if (err instanceof SmplNotFoundError || err instanceof SmplConflictError || err instanceof SmplValidationError || err instanceof SmplError) {
648
+ throw err;
649
+ }
650
+ if (err instanceof TypeError) {
651
+ throw new SmplConnectionError(`Network error: ${err.message}`);
652
+ }
653
+ throw new SmplConnectionError(
654
+ `Request failed: ${err instanceof Error ? err.message : String(err)}`
655
+ );
656
+ }
657
+ function buildRequestBody(options) {
658
+ const attrs = {
659
+ name: options.name
660
+ };
661
+ if (options.key !== void 0) attrs.key = options.key;
662
+ if (options.description !== void 0) attrs.description = options.description;
663
+ if (options.parent !== void 0) attrs.parent = options.parent;
664
+ if (options.values !== void 0)
665
+ attrs.values = options.values;
666
+ if (options.environments !== void 0)
667
+ attrs.environments = options.environments;
668
+ return {
669
+ data: {
670
+ id: options.id ?? null,
671
+ type: "config",
672
+ attributes: attrs
673
+ }
674
+ };
675
+ }
676
+ var ConfigClient = class {
677
+ /** @internal — used by Config instances for reconnecting and WebSocket auth. */
678
+ _apiKey;
679
+ /** @internal */
680
+ _baseUrl = BASE_URL;
681
+ /** @internal */
682
+ _http;
683
+ /** @internal */
684
+ constructor(apiKey, timeout) {
685
+ this._apiKey = apiKey;
686
+ const ms = timeout ?? 3e4;
687
+ this._http = (0, import_openapi_fetch.default)({
688
+ baseUrl: BASE_URL,
689
+ headers: {
690
+ Authorization: `Bearer ${apiKey}`,
691
+ Accept: "application/json"
692
+ },
693
+ // openapi-fetch custom fetch receives a pre-built Request object
694
+ fetch: async (request) => {
695
+ const controller = new AbortController();
696
+ const timer = setTimeout(() => controller.abort(), ms);
697
+ try {
698
+ return await fetch(new Request(request, { signal: controller.signal }));
699
+ } catch (err) {
700
+ if (err instanceof DOMException && err.name === "AbortError") {
701
+ throw new SmplTimeoutError(`Request timed out after ${ms}ms`);
702
+ }
703
+ throw err;
704
+ } finally {
705
+ clearTimeout(timer);
706
+ }
707
+ }
708
+ });
217
709
  }
218
710
  /**
219
- * Send a GET request.
711
+ * Fetch a single config by key or UUID.
220
712
  *
221
- * @param path - URL path relative to `baseUrl` (e.g. `/api/v1/configs`).
222
- * @param params - Optional query parameters.
223
- * @returns Parsed JSON response body.
713
+ * Exactly one of `key` or `id` must be provided.
714
+ *
715
+ * @throws {SmplNotFoundError} If no matching config exists.
224
716
  */
225
- async get(path, params) {
226
- return this.request("GET", path, void 0, params);
717
+ async get(options) {
718
+ const { key, id } = options;
719
+ if (key === void 0 === (id === void 0)) {
720
+ throw new Error("Exactly one of 'key' or 'id' must be provided.");
721
+ }
722
+ return id !== void 0 ? this._getById(id) : this._getByKey(key);
227
723
  }
228
724
  /**
229
- * Send a POST request with a JSON body.
230
- *
231
- * @param path - URL path relative to `baseUrl`.
232
- * @param body - JSON-serializable request body.
233
- * @returns Parsed JSON response body.
725
+ * List all configs for the account.
234
726
  */
235
- async post(path, body) {
236
- return this.request("POST", path, body);
727
+ async list() {
728
+ let data;
729
+ try {
730
+ const result = await this._http.GET("/api/v1/configs", {});
731
+ if (result.error !== void 0) await checkError(result.response, "Failed to list configs");
732
+ data = result.data;
733
+ } catch (err) {
734
+ wrapFetchError(err);
735
+ }
736
+ if (!data) return [];
737
+ return data.data.map((r) => resourceToConfig(r, this));
237
738
  }
238
739
  /**
239
- * Send a PUT request with a JSON body.
740
+ * Create a new config.
240
741
  *
241
- * @param path - URL path relative to `baseUrl`.
242
- * @param body - JSON-serializable request body.
243
- * @returns Parsed JSON response body.
742
+ * @throws {SmplValidationError} If the server rejects the request.
244
743
  */
245
- async put(path, body) {
246
- return this.request("PUT", path, body);
744
+ async create(options) {
745
+ const body = buildRequestBody({
746
+ name: options.name,
747
+ key: options.key,
748
+ description: options.description,
749
+ parent: options.parent,
750
+ values: options.values
751
+ });
752
+ let data;
753
+ try {
754
+ const result = await this._http.POST("/api/v1/configs", { body });
755
+ if (result.error !== void 0) await checkError(result.response, "Failed to create config");
756
+ data = result.data;
757
+ } catch (err) {
758
+ wrapFetchError(err);
759
+ }
760
+ if (!data || !data.data) throw new SmplValidationError("Failed to create config");
761
+ return resourceToConfig(data.data, this);
247
762
  }
248
763
  /**
249
- * Send a DELETE request.
764
+ * Delete a config by UUID.
250
765
  *
251
- * @param path - URL path relative to `baseUrl`.
252
- * @returns Parsed JSON response body (empty object for 204 responses).
766
+ * @throws {SmplNotFoundError} If the config does not exist.
767
+ * @throws {SmplConflictError} If the config has child configs.
253
768
  */
254
- async delete(path) {
255
- return this.request("DELETE", path);
769
+ async delete(configId) {
770
+ try {
771
+ const result = await this._http.DELETE("/api/v1/configs/{id}", {
772
+ params: { path: { id: configId } }
773
+ });
774
+ if (result.error !== void 0 && result.response.status !== 204)
775
+ await checkError(result.response, `Failed to delete config ${configId}`);
776
+ } catch (err) {
777
+ wrapFetchError(err);
778
+ }
256
779
  }
257
780
  /**
258
- * Core request method. Handles headers, timeouts, and error mapping.
781
+ * Internal: PUT a full config update and return the updated model.
782
+ *
783
+ * Called by {@link Config} instance methods.
784
+ * @internal
259
785
  */
260
- async request(method, path, body, params) {
261
- let url = `${this.baseUrl}${path}`;
262
- if (params) {
263
- const searchParams = new URLSearchParams(params);
264
- url += `?${searchParams.toString()}`;
265
- }
266
- const headers = {
267
- Authorization: buildAuthHeader(this.apiKey),
268
- "User-Agent": `smplkit-typescript-sdk/${SDK_VERSION}`,
269
- Accept: "application/vnd.api+json"
270
- };
271
- if (body !== void 0) {
272
- headers["Content-Type"] = "application/vnd.api+json";
273
- }
274
- const controller = new AbortController();
275
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
276
- let response;
786
+ async _updateConfig(payload) {
787
+ const body = buildRequestBody({
788
+ id: payload.configId,
789
+ name: payload.name,
790
+ key: payload.key,
791
+ description: payload.description,
792
+ parent: payload.parent,
793
+ values: payload.values,
794
+ environments: payload.environments
795
+ });
796
+ let data;
277
797
  try {
278
- response = await fetch(url, {
279
- method,
280
- headers,
281
- body: body !== void 0 ? JSON.stringify(body) : void 0,
282
- signal: controller.signal
798
+ const result = await this._http.PUT("/api/v1/configs/{id}", {
799
+ params: { path: { id: payload.configId } },
800
+ body
283
801
  });
284
- } catch (error) {
285
- clearTimeout(timeoutId);
286
- if (error instanceof DOMException && error.name === "AbortError") {
287
- throw new SmplTimeoutError(`Request timed out after ${this.timeout}ms`);
288
- }
289
- if (error instanceof TypeError) {
290
- throw new SmplConnectionError(`Network error: ${error.message}`);
291
- }
292
- throw new SmplConnectionError(
293
- `Request failed: ${error instanceof Error ? error.message : String(error)}`
294
- );
295
- } finally {
296
- clearTimeout(timeoutId);
297
- }
298
- if (response.status === 204) {
299
- return {};
300
- }
301
- const responseText = await response.text();
302
- if (!response.ok) {
303
- this.throwForStatus(response.status, responseText);
802
+ if (result.error !== void 0)
803
+ await checkError(result.response, `Failed to update config ${payload.configId}`);
804
+ data = result.data;
805
+ } catch (err) {
806
+ wrapFetchError(err);
304
807
  }
808
+ if (!data || !data.data)
809
+ throw new SmplValidationError(`Failed to update config ${payload.configId}`);
810
+ return resourceToConfig(data.data, this);
811
+ }
812
+ // ---- Private helpers ----
813
+ async _getById(configId) {
814
+ let data;
305
815
  try {
306
- return JSON.parse(responseText);
307
- } catch {
308
- throw new SmplError(`Invalid JSON response: ${responseText}`, response.status, responseText);
816
+ const result = await this._http.GET("/api/v1/configs/{id}", {
817
+ params: { path: { id: configId } }
818
+ });
819
+ if (result.error !== void 0)
820
+ await checkError(result.response, `Config ${configId} not found`);
821
+ data = result.data;
822
+ } catch (err) {
823
+ wrapFetchError(err);
309
824
  }
825
+ if (!data || !data.data) throw new SmplNotFoundError(`Config ${configId} not found`);
826
+ return resourceToConfig(data.data, this);
310
827
  }
311
- /**
312
- * Map HTTP error status codes to typed SDK exceptions.
313
- *
314
- * @throws {SmplNotFoundError} On 404.
315
- * @throws {SmplConflictError} On 409.
316
- * @throws {SmplValidationError} On 422.
317
- * @throws {SmplError} On any other non-2xx status.
318
- */
319
- throwForStatus(status, body) {
320
- switch (status) {
321
- case 404:
322
- throw new SmplNotFoundError(body, 404, body);
323
- case 409:
324
- throw new SmplConflictError(body, 409, body);
325
- case 422:
326
- throw new SmplValidationError(body, 422, body);
327
- default:
328
- throw new SmplError(`HTTP ${status}: ${body}`, status, body);
828
+ async _getByKey(key) {
829
+ let data;
830
+ try {
831
+ const result = await this._http.GET("/api/v1/configs", {
832
+ params: { query: { "filter[key]": key } }
833
+ });
834
+ if (result.error !== void 0)
835
+ await checkError(result.response, `Config with key '${key}' not found`);
836
+ data = result.data;
837
+ } catch (err) {
838
+ wrapFetchError(err);
839
+ }
840
+ if (!data || !data.data || data.data.length === 0) {
841
+ throw new SmplNotFoundError(`Config with key '${key}' not found`);
329
842
  }
843
+ return resourceToConfig(data.data[0], this);
330
844
  }
331
845
  };
332
846
 
333
847
  // src/client.ts
334
- var DEFAULT_BASE_URL = "https://config.smplkit.com";
335
848
  var SmplkitClient = class {
336
849
  /** Client for config management-plane operations. */
337
850
  config;
338
- /** @internal */
339
- transport;
340
851
  constructor(options) {
341
852
  if (!options.apiKey) {
342
853
  throw new Error("apiKey is required");
343
854
  }
344
- this.transport = new Transport({
345
- apiKey: options.apiKey,
346
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
347
- timeout: options.timeout
348
- });
349
- this.config = new ConfigClient(this.transport);
855
+ this.config = new ConfigClient(options.apiKey, options.timeout);
350
856
  }
351
857
  };
858
+
859
+ // src/index.ts
860
+ init_runtime();
352
861
  // Annotate the CommonJS export names for ESM import in node:
353
862
  0 && (module.exports = {
863
+ Config,
354
864
  ConfigClient,
865
+ ConfigRuntime,
355
866
  SmplConflictError,
356
867
  SmplConnectionError,
357
868
  SmplError,