@checkstack/secrets-backend 0.3.3 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @checkstack/secrets-backend
2
2
 
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 43e4484: Kill the redundant active-backend-id read N+1 in secret run resolution.
8
+ Behavior unchanged; performance-only: the same env vars and masking context are
9
+ produced for a run, only the number of active-backend-id config reads drops from
10
+ N (one per distinct secret name) to 1.
11
+
12
+ - The internal `SecretStore` interface gains an optional
13
+ `resolveMany(names: string[]): Promise<Map<string, string>>` batch path
14
+ (the single `resolve` stays for back-compat and single-secret callers).
15
+ - The active-backend store (`createActiveBackendStore`) implements
16
+ `resolveMany`: it resolves the active backend id ONCE for the whole batch and
17
+ then fetches each distinct name through that single backend, de-duping names
18
+ and throwing the same `Secret not found: NAME` on any absent value. The
19
+ per-name backend fetch is inherent; the removed redundancy is the per-name
20
+ active-backend-id config read.
21
+ - `SecretResolverService.resolveForRun` now collects the distinct secret names
22
+ (as before) and resolves them via one `resolveMany` call instead of a per-name
23
+ `resolve` loop. Stores without a batch path fall back to looping `resolve`,
24
+ so behavior is identical for every caller.
25
+
26
+ State & scale: the active backend id still resolves from the shared config
27
+ store, so every pod returns the same answer; no process-local or duplicated
28
+ state is introduced.
29
+
30
+ - Updated dependencies [43e4484]
31
+ - Updated dependencies [43e4484]
32
+ - @checkstack/backend-api@0.31.1
33
+ - @checkstack/command-backend@0.2.22
34
+
3
35
  ## 0.3.3
4
36
 
5
37
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/secrets-backend",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Secrets platform backend: resolver service, masking, backend extension point, RPC router",
5
5
  "author": "Checkstack contributors",
6
6
  "license": "Elastic-2.0",
@@ -25,8 +25,8 @@
25
25
  "test": "bun test"
26
26
  },
27
27
  "dependencies": {
28
- "@checkstack/backend-api": "0.31.0",
29
- "@checkstack/command-backend": "0.2.21",
28
+ "@checkstack/backend-api": "0.31.1",
29
+ "@checkstack/command-backend": "0.2.22",
30
30
  "@checkstack/common": "0.22.0",
31
31
  "@checkstack/secrets-common": "0.3.2",
32
32
  "@orpc/server": "^1.14.4",
@@ -37,10 +37,10 @@
37
37
  "devDependencies": {
38
38
  "@checkstack/scripts": "0.7.3",
39
39
  "@checkstack/dev-server": "2.2.8",
40
- "@checkstack/backend": "0.24.0",
40
+ "@checkstack/backend": "0.24.1",
41
41
  "@checkstack/tsconfig": "0.0.7",
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
- "@checkstack/test-utils-backend": "0.1.55",
43
+ "@checkstack/test-utils-backend": "0.1.56",
44
44
  "@types/bun": "^1.3.5",
45
45
  "@types/node": "^20.0.0",
46
46
  "drizzle-kit": "^0.31.10",
@@ -43,4 +43,94 @@ describe("createActiveBackendStore", () => {
43
43
  "Secret not found: absent",
44
44
  );
45
45
  });
46
+
47
+ describe("resolveMany", () => {
48
+ it("resolves the active backend id exactly once for the whole batch", async () => {
49
+ const registry = createSecretBackendRegistry();
50
+ registry.register(
51
+ fakeBackend("local", { A: "a-val", B: "b-val", C: "c-val" }),
52
+ );
53
+
54
+ let idReads = 0;
55
+ const store = createActiveBackendStore({
56
+ backends: registry,
57
+ getActiveBackendId: async () => {
58
+ idReads++;
59
+ return "local";
60
+ },
61
+ });
62
+
63
+ const resolved = await store.resolveMany!(["A", "B", "C"]);
64
+ expect(idReads).toBe(1);
65
+ expect(resolved).toEqual(
66
+ new Map([
67
+ ["A", "a-val"],
68
+ ["B", "b-val"],
69
+ ["C", "c-val"],
70
+ ]),
71
+ );
72
+ });
73
+
74
+ it("de-dupes names and reads the active backend id once", async () => {
75
+ const registry = createSecretBackendRegistry();
76
+ let gets = 0;
77
+ registry.register({
78
+ id: "local",
79
+ get: async ({ name }) => {
80
+ gets++;
81
+ return `val-${name}`;
82
+ },
83
+ list: async () => [],
84
+ });
85
+
86
+ let idReads = 0;
87
+ const store = createActiveBackendStore({
88
+ backends: registry,
89
+ getActiveBackendId: async () => {
90
+ idReads++;
91
+ return "local";
92
+ },
93
+ });
94
+
95
+ const resolved = await store.resolveMany!(["dup", "dup", "other"]);
96
+ expect(idReads).toBe(1);
97
+ expect(gets).toBe(2); // distinct names only
98
+ expect(resolved).toEqual(
99
+ new Map([
100
+ ["dup", "val-dup"],
101
+ ["other", "val-other"],
102
+ ]),
103
+ );
104
+ });
105
+
106
+ it("returns an empty map (and reads no backend id) for empty input", async () => {
107
+ const registry = createSecretBackendRegistry();
108
+ registry.register(fakeBackend("local", {}));
109
+
110
+ let idReads = 0;
111
+ const store = createActiveBackendStore({
112
+ backends: registry,
113
+ getActiveBackendId: async () => {
114
+ idReads++;
115
+ return "local";
116
+ },
117
+ });
118
+
119
+ const resolved = await store.resolveMany!([]);
120
+ expect(resolved.size).toBe(0);
121
+ expect(idReads).toBe(0);
122
+ });
123
+
124
+ it("throws Secret not found on any absent name", async () => {
125
+ const registry = createSecretBackendRegistry();
126
+ registry.register(fakeBackend("local", { A: "a-val" }));
127
+ const store = createActiveBackendStore({
128
+ backends: registry,
129
+ getActiveBackendId: async () => "local",
130
+ });
131
+ await expect(store.resolveMany!(["A", "missing"])).rejects.toThrow(
132
+ "Secret not found: missing",
133
+ );
134
+ });
135
+ });
46
136
  });
@@ -24,5 +24,26 @@ export function createActiveBackendStore({
24
24
  }
25
25
  return value;
26
26
  },
27
+
28
+ resolveMany: async (names: string[]): Promise<Map<string, string>> => {
29
+ const distinct = [...new Set(names)];
30
+ const resolved = new Map<string, string>();
31
+ if (distinct.length === 0) {
32
+ return resolved;
33
+ }
34
+ // Resolve the active backend id ONCE for the whole batch. The id
35
+ // read goes through the config store (a DB read), so doing it per
36
+ // name — as a `resolve` loop would — is the redundant N+1 this path
37
+ // removes. The per-name `backend.get` fetch is inherent.
38
+ const backend = backends.get(await getActiveBackendId());
39
+ for (const name of distinct) {
40
+ const value = await backend.get({ name });
41
+ if (value === undefined) {
42
+ throw new Error(`Secret not found: ${name}`);
43
+ }
44
+ resolved.set(name, value);
45
+ }
46
+ return resolved;
47
+ },
27
48
  };
28
49
  }
@@ -65,7 +65,7 @@ describe("SecretResolverService.resolveForRun", () => {
65
65
  ).rejects.toThrow("Secret not found: absent");
66
66
  });
67
67
 
68
- it("resolves each distinct secret once even when reused", async () => {
68
+ it("resolves each distinct secret once even when reused (resolve fallback)", async () => {
69
69
  let calls = 0;
70
70
  const counting: SecretStore = {
71
71
  resolve: async (name) => {
@@ -84,4 +84,35 @@ describe("SecretResolverService.resolveForRun", () => {
84
84
  // "same" + "other" → 2 distinct resolutions despite 3 references.
85
85
  expect(calls).toBe(2);
86
86
  });
87
+
88
+ it("uses resolveMany once for the whole batch when the store provides it", async () => {
89
+ let manyCalls = 0;
90
+ let singleCalls = 0;
91
+ const batching: SecretStore = {
92
+ resolve: async (name) => {
93
+ singleCalls++;
94
+ return `val-${name}`;
95
+ },
96
+ resolveMany: async (names) => {
97
+ manyCalls++;
98
+ return new Map(names.map((name) => [name, `val-${name}`]));
99
+ },
100
+ };
101
+ const service = createSecretResolverService({ secretStore: batching });
102
+ const { env } = await service.resolveForRun({
103
+ secretEnv: {
104
+ A: "${{ secrets.same }}",
105
+ B: "${{ secrets.same }}",
106
+ C: "${{ secrets.other }}",
107
+ },
108
+ });
109
+ // One batch call, never the per-name single resolve.
110
+ expect(manyCalls).toBe(1);
111
+ expect(singleCalls).toBe(0);
112
+ expect(env).toEqual({
113
+ A: "val-same",
114
+ B: "val-same",
115
+ C: "val-other",
116
+ });
117
+ });
87
118
  });
@@ -85,10 +85,15 @@ export function createSecretResolverService({
85
85
  collectSecretNames({ value: Object.values(normalized) }),
86
86
  );
87
87
 
88
- const resolved = new Map<string, string>();
89
- for (const name of names) {
90
- resolved.set(name, await secretStore.resolve(name));
91
- }
88
+ // Resolve the whole batch in one shot. `resolveMany` (active-backend
89
+ // store) resolves the active backend id ONCE for every name instead
90
+ // of the per-name config read the old `resolve` loop incurred. Stores
91
+ // without a batch path (e.g. a plain `resolve`-only literal) fall back
92
+ // to looping `resolve` — same result, no batch optimization.
93
+ const nameList = [...names];
94
+ const resolved = secretStore.resolveMany
95
+ ? await secretStore.resolveMany(nameList)
96
+ : await resolveEach({ secretStore, names: nameList });
92
97
 
93
98
  // Build the env by substituting templates in each mapping value.
94
99
  const env: Record<string, string> = {};
@@ -102,6 +107,24 @@ export function createSecretResolverService({
102
107
  };
103
108
  }
104
109
 
110
+ /**
111
+ * Fallback batch resolution for a {@link SecretStore} without a native
112
+ * `resolveMany`: resolve each distinct name via the single `resolve`.
113
+ */
114
+ async function resolveEach({
115
+ secretStore,
116
+ names,
117
+ }: {
118
+ secretStore: SecretStore;
119
+ names: string[];
120
+ }): Promise<Map<string, string>> {
121
+ const resolved = new Map<string, string>();
122
+ for (const name of names) {
123
+ resolved.set(name, await secretStore.resolve(name));
124
+ }
125
+ return resolved;
126
+ }
127
+
105
128
  const TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
106
129
 
107
130
  function substituteTemplate({
@@ -8,6 +8,16 @@ import { isSecretSchema } from "@checkstack/backend-api";
8
8
  */
9
9
  export interface SecretStore {
10
10
  resolve: (name: string) => Promise<string>;
11
+ /**
12
+ * Resolve many secret names in one batch. Optional — implementations
13
+ * backed by the active backend override this to resolve the active
14
+ * backend id ONCE for the whole batch (instead of the per-name config
15
+ * read `resolve` incurs), returning a `name → value` map keyed by the
16
+ * distinct input names. Throws `Secret not found: NAME` on any absent
17
+ * name, matching `resolve`. Callers that need a batch but face a store
18
+ * without this method fall back to looping `resolve`.
19
+ */
20
+ resolveMany?: (names: string[]) => Promise<Map<string, string>>;
11
21
  }
12
22
 
13
23
  /**