Skip to content

Commit 9760f4b

Browse files
authored
Merge pull request #1648 from tailor-platform/fix/namespace-bundle-cache-key
fix(cli): isolate resolver bundle cache keys
2 parents c2a4277 + 84d0fb9 commit 9760f4b

12 files changed

Lines changed: 177 additions & 21 deletions

File tree

.changeset/quiet-bundles-warm.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tailor-platform/sdk": patch
3+
---
4+
5+
Keep resolver bundle cache entries separate across namespaces.

example/tests/scripts/generate_files.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ async function listGeneratedFiles(dirPath: string, depth = 0, maxDepth = 3): Pro
9595
const generatorsCompatDir = "tests/fixtures/generators";
9696
const pluginsCompatDir = "tests/fixtures/plugins";
9797

98+
function bundledScriptFileName(kind: string, name: string): string {
99+
if (kind !== "resolvers") return name;
100+
101+
const separatorIndex = name.indexOf(":");
102+
return separatorIndex === -1 ? name : name.slice(separatorIndex + 1);
103+
}
104+
98105
export async function generateCompatFiles(): Promise<void> {
99106
for (const dir of [generatorsCompatDir, pluginsCompatDir]) {
100107
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
@@ -122,7 +129,7 @@ export async function generateCompatFiles(): Promise<void> {
122129
if (scripts.size === 0) continue;
123130
fs.mkdirSync(dirPath, { recursive: true });
124131
for (const [name, code] of scripts) {
125-
fs.writeFileSync(path.join(dirPath, `${name}.js`), code);
132+
fs.writeFileSync(path.join(dirPath, `${bundledScriptFileName(kind, name)}.js`), code);
126133
}
127134
}
128135
}

packages/sdk/src/cli/cache/bundle-cache.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,7 @@ describe("createBundleCache", () => {
190190
expect(entry?.outputFiles).toHaveLength(1);
191191
expect(entry?.outputFiles[0]?.contentHash).toMatch(/^[0-9a-f]{64}$/);
192192

193-
// Cache filename replaces the ":" in "kind:name" with "_"
194-
const cachedBundlePath = path.join(cacheDir, "bundles", "executor_myExecutor.js");
195-
expect(fs.existsSync(cachedBundlePath)).toBe(true);
196-
expect(fs.readFileSync(cachedBundlePath, "utf-8")).toBe("bundled executor");
193+
expect(store.restoreBundleContent("executor:myExecutor")).toBe("bundled executor");
197194
});
198195

199196
test("updates existing cache entry on re-save", () => {
@@ -244,6 +241,39 @@ describe("createBundleCache", () => {
244241
expect(store.getEntry("getUser")).toBeUndefined();
245242
expect(store.getEntry("resolver")).toBeUndefined();
246243
});
244+
245+
test("cache key includes namespace when provided", () => {
246+
const store = createCacheStore({ cacheDir });
247+
const cache = createBundleCache(store);
248+
const firstSourceFile = writeFile("src/first/resolver.ts", "export default {}");
249+
const secondSourceFile = writeFile("src/second/resolver.ts", "export default {}");
250+
251+
cache.save({
252+
kind: "resolver",
253+
namespace: "first",
254+
name: "getUser",
255+
sourceFile: firstSourceFile,
256+
content: "first bundle",
257+
dependencyPaths: [firstSourceFile],
258+
});
259+
cache.save({
260+
kind: "resolver",
261+
namespace: "second",
262+
name: "getUser",
263+
sourceFile: secondSourceFile,
264+
content: "second bundle",
265+
dependencyPaths: [secondSourceFile],
266+
});
267+
268+
expect(store.getEntry("resolver:first:getUser")).toBeDefined();
269+
expect(store.getEntry("resolver:second:getUser")).toBeDefined();
270+
expect(cache.tryRestore({ kind: "resolver", namespace: "first", name: "getUser" })).toBe(
271+
"first bundle",
272+
);
273+
expect(cache.tryRestore({ kind: "resolver", namespace: "second", name: "getUser" })).toBe(
274+
"second bundle",
275+
);
276+
});
247277
});
248278
});
249279

@@ -351,6 +381,47 @@ describe("withCache", () => {
351381
});
352382
expect(build).toHaveBeenCalledOnce();
353383
});
384+
385+
test("keeps entries separate by namespace", async () => {
386+
const cache = createBundleCache(createCacheStore({ cacheDir }));
387+
const firstSourceFile = writeFile("src/first/resolver.ts", "export default {}");
388+
const secondSourceFile = writeFile("src/second/resolver.ts", "export default {}");
389+
const buildFirst = vi.fn(async () => "first bundle");
390+
const buildSecond = vi.fn(async () => "second bundle");
391+
392+
await withCache({
393+
cache,
394+
kind: "resolver",
395+
namespace: "first",
396+
name: "getUser",
397+
sourceFile: firstSourceFile,
398+
contextHash: undefined,
399+
build: buildFirst,
400+
});
401+
await withCache({
402+
cache,
403+
kind: "resolver",
404+
namespace: "second",
405+
name: "getUser",
406+
sourceFile: secondSourceFile,
407+
contextHash: undefined,
408+
build: buildSecond,
409+
});
410+
411+
buildFirst.mockClear();
412+
const result = await withCache({
413+
cache,
414+
kind: "resolver",
415+
namespace: "first",
416+
name: "getUser",
417+
sourceFile: firstSourceFile,
418+
contextHash: undefined,
419+
build: buildFirst,
420+
});
421+
422+
expect(buildFirst).not.toHaveBeenCalled();
423+
expect(result).toBe("first bundle");
424+
});
354425
});
355426

356427
describe("computeBundlerContextHash", () => {

packages/sdk/src/cli/cache/bundle-cache.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ type BundleKind =
1515

1616
type BundleCacheRestoreParams = {
1717
kind: BundleKind;
18+
namespace?: string;
1819
name: string;
1920
/** Optional hash of non-file context (e.g., env variables) to include in cache validation. */
2021
contextHash?: string;
2122
};
2223

2324
type BundleCacheSaveParams = {
2425
kind: BundleKind;
26+
namespace?: string;
2527
name: string;
2628
sourceFile: string;
2729
content: string;
@@ -41,8 +43,8 @@ type BundleCache = {
4143
save(params: BundleCacheSaveParams): void;
4244
};
4345

44-
function buildCacheKey(kind: string, name: string): string {
45-
return `${kind}:${name}`;
46+
function buildCacheKey(kind: string, name: string, namespace?: string): string {
47+
return namespace ? `${kind}:${namespace}:${name}` : `${kind}:${name}`;
4648
}
4749

4850
function combineHash(fileHash: string, contextHash?: string): string {
@@ -90,6 +92,7 @@ function computeBundlerContextHash(params: ComputeBundlerContextHashParams): str
9092
type WithCacheParams = {
9193
cache: BundleCache | undefined;
9294
kind: BundleKind;
95+
namespace?: string;
9396
name: string;
9497
sourceFile: string;
9598
contextHash: string | undefined;
@@ -104,13 +107,13 @@ type WithCacheParams = {
104107
* @returns The bundled code string
105108
*/
106109
async function withCache(params: WithCacheParams): Promise<string> {
107-
const { cache, kind, name, sourceFile, contextHash, build } = params;
110+
const { cache, kind, namespace, name, sourceFile, contextHash, build } = params;
108111

109112
if (!cache) {
110113
return await build([]);
111114
}
112115

113-
const content = cache.tryRestore({ kind, name, contextHash });
116+
const content = cache.tryRestore({ kind, namespace, name, contextHash });
114117
if (content !== undefined) {
115118
logger.debug(` ${styles.dim("cached")}: ${name}`);
116119
return content;
@@ -119,7 +122,15 @@ async function withCache(params: WithCacheParams): Promise<string> {
119122
const { plugin, getResult } = createDepCollectorPlugin();
120123
const code = await build([plugin]);
121124

122-
cache.save({ kind, name, sourceFile, content: code, dependencyPaths: getResult(), contextHash });
125+
cache.save({
126+
kind,
127+
namespace,
128+
name,
129+
sourceFile,
130+
content: code,
131+
dependencyPaths: getResult(),
132+
contextHash,
133+
});
123134

124135
return code;
125136
}
@@ -131,7 +142,7 @@ async function withCache(params: WithCacheParams): Promise<string> {
131142
*/
132143
function createBundleCache(store: CacheStore): BundleCache {
133144
function tryRestore(params: BundleCacheRestoreParams): string | undefined {
134-
const cacheKey = buildCacheKey(params.kind, params.name);
145+
const cacheKey = buildCacheKey(params.kind, params.name, params.namespace);
135146
const entry = store.getEntry(cacheKey);
136147

137148
if (!entry) {
@@ -155,8 +166,8 @@ function createBundleCache(store: CacheStore): BundleCache {
155166
}
156167

157168
function save(params: BundleCacheSaveParams): void {
158-
const { kind, name, sourceFile, content, dependencyPaths, contextHash } = params;
159-
const cacheKey = buildCacheKey(kind, name);
169+
const { kind, namespace, name, sourceFile, content, dependencyPaths, contextHash } = params;
170+
const cacheKey = buildCacheKey(kind, name, namespace);
160171
// Always include sourceFile in dependency paths so that changes to the
161172
// source file itself are detected even when dep-collector only finds
162173
// node_modules imports (which are filtered out).

packages/sdk/src/cli/cache/store.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ describe("createCacheStore", () => {
149149
});
150150
});
151151

152+
describe("storeBundleContent / restoreBundleContent", () => {
153+
test("keeps bundle content separate when cache keys share an underscore form", () => {
154+
const store = createCacheStore({ cacheDir });
155+
156+
store.storeBundleContent("resolver:foo_bar:baz", "first bundle");
157+
store.storeBundleContent("resolver:foo:bar_baz", "second bundle");
158+
159+
expect(store.restoreBundleContent("resolver:foo_bar:baz")).toBe("first bundle");
160+
expect(store.restoreBundleContent("resolver:foo:bar_baz")).toBe("second bundle");
161+
});
162+
});
163+
152164
describe("clean", () => {
153165
test("removes entire cache directory", () => {
154166
const store = createCacheStore({ cacheDir });

packages/sdk/src/cli/cache/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "node:fs";
22
import * as path from "pathe";
3+
import { hashContent } from "./hasher";
34
import { cacheManifestSchema } from "./types";
45
import type { CacheConfig, CacheEntry, CacheManifest } from "./types";
56

@@ -48,7 +49,7 @@ function createCacheStore(config: CacheConfig): CacheStore {
4849
}
4950

5051
function bundlePath(cacheKey: string): string {
51-
return path.join(bundlesDir(), `${cacheKey.replaceAll(":", "_")}.js`);
52+
return path.join(bundlesDir(), `${hashContent(cacheKey)}.js`);
5253
}
5354

5455
function loadManifest(): CacheManifest | undefined {

packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "node:fs";
22
import * as path from "node:path";
33
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
4+
import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key";
45
import { setupInvokerMock, setupTailordbMock, setupTailorErrorsMock } from "#/utils/test/mock";
56
import { prepareFixtures } from "./prepare";
67
import type { BundledScripts } from "#/cli/commands/deploy/function-registry";
@@ -97,17 +98,18 @@ describe("deploy command integration tests", () => {
9798

9899
describe("validation", () => {
99100
let main: MainFunction;
101+
const addResolverBundleKey = resolverBundleKey("test-resolver", "add");
100102

101103
beforeAll(async () => {
102-
const code = bundledScripts.resolvers.get("add");
104+
const code = bundledScripts.resolvers.get(addResolverBundleKey);
103105
if (!code) {
104106
throw new Error("resolvers/add bundle not found");
105107
}
106108
main = await importFromCode(code, "resolvers/add");
107109
});
108110

109111
test("resolvers/add bundle is defined", () => {
110-
expect(bundledScripts.resolvers.get("add")).toBeDefined();
112+
expect(bundledScripts.resolvers.get(addResolverBundleKey)).toBeDefined();
111113
});
112114

113115
test("resolvers/add validates input correctly - valid values", async () => {

packages/sdk/src/cli/commands/deploy/function-registry.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, test, expect, vi, beforeEach } from "vitest";
2+
import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key";
23
import {
34
applyFunctionRegistry,
45
authHookFunctionName,
@@ -481,8 +482,8 @@ describe("collectFunctionEntries", () => {
481482
test("collects resolver entries with correct names and hashes", () => {
482483
const scripts = createBundledScripts({
483484
resolvers: new Map([
484-
["getUser", "// getUser code"],
485-
["listUsers", "// listUsers code"],
485+
[resolverBundleKey("my-ns", "getUser"), "// getUser code"],
486+
[resolverBundleKey("my-ns", "listUsers"), "// listUsers code"],
486487
]),
487488
});
488489

@@ -508,6 +509,40 @@ describe("collectFunctionEntries", () => {
508509
expect(entries[1]!.name).toBe(resolverFunctionName("my-ns", "listUsers"));
509510
});
510511

512+
test("keeps resolver entries separate when namespaces share resolver names", () => {
513+
const scripts = createBundledScripts({
514+
resolvers: new Map([
515+
[resolverBundleKey("first", "getUser"), "// first getUser code"],
516+
[resolverBundleKey("second", "getUser"), "// second getUser code"],
517+
]),
518+
});
519+
520+
const app = createMockApplication({
521+
resolverServices: [
522+
{
523+
namespace: "first",
524+
resolvers: {
525+
getUser: { name: "getUser" },
526+
},
527+
},
528+
{
529+
namespace: "second",
530+
resolvers: {
531+
getUser: { name: "getUser" },
532+
},
533+
},
534+
],
535+
});
536+
537+
const entries = collectFunctionEntries(app, [], scripts);
538+
539+
expect(entries).toHaveLength(2);
540+
expect(entries[0]!.name).toBe(resolverFunctionName("first", "getUser"));
541+
expect(entries[0]!.scriptContent).toBe("// first getUser code");
542+
expect(entries[1]!.name).toBe(resolverFunctionName("second", "getUser"));
543+
expect(entries[1]!.scriptContent).toBe("// second getUser code");
544+
});
545+
511546
test("collects executor entries only for function/jobFunction kinds", () => {
512547
const scripts = createBundledScripts({
513548
executors: new Map([

packages/sdk/src/cli/commands/deploy/function-registry.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as crypto from "node:crypto";
22
import { logger } from "#/cli/shared/logger";
3+
import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key";
34
import { createChangeSet, type ChangeSet, type HasName } from "./change-set";
45
import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "./label";
56
import {
@@ -175,9 +176,13 @@ export function collectFunctionEntries(
175176
for (const app of application.applications) {
176177
for (const pipeline of app.resolverServices) {
177178
for (const resolver of Object.values(pipeline.resolvers)) {
178-
const content = bundledScripts.resolvers.get(resolver.name);
179+
const content = bundledScripts.resolvers.get(
180+
resolverBundleKey(pipeline.namespace, resolver.name),
181+
);
179182
if (!content) {
180-
logger.warn(`Bundled code not found for resolver: ${resolver.name}`);
183+
logger.warn(
184+
`Bundled code not found for resolver: ${pipeline.namespace}/${resolver.name}`,
185+
);
181186
continue;
182187
}
183188
entries.push({

packages/sdk/src/cli/services/application.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { type LoadedConfig } from "#/cli/shared/config-loader";
2424
import { getDistDir } from "#/cli/shared/dist-dir";
2525
import { resolveInlineSourcemap } from "#/cli/shared/inline-sourcemap";
2626
import { logger } from "#/cli/shared/logger";
27+
import { resolverBundleKey } from "#/cli/shared/resolver-bundle-key";
2728
import { buildTriggerContext } from "#/cli/shared/trigger-context";
2829
import {
2930
type AppConfig,
@@ -539,7 +540,7 @@ export async function loadApplication(
539540
bundleLogLevel,
540541
);
541542
for (const [name, code] of resolverBundles) {
542-
bundledScripts.resolvers.set(name, code);
543+
bundledScripts.resolvers.set(resolverBundleKey(pipeline.namespace, name), code);
543544
}
544545
}
545546

0 commit comments

Comments
 (0)