From b6d7918e6260bce702fb39cb7888904896eff9c5 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 13 Aug 2026 14:45:33 -0400 Subject: [PATCH 1/3] Track runtime dependencies in canonical RRI form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency tracker's `canonicalURL` guard dropped any identifier without an `http(s)` scheme, so prefix-form identifiers could not be tracked at all. Every caller therefore converted to a URL first: `card-api` through `Loader.dependencyTrackingKey`, and the loader internally through `canonicalizeTrackingKey`, which folded each module onto its virtual-alias URL. The forms were then converted back — three of the four snapshot consumers run the deps through `unresolveURLs`, and the index writer folds them again before persisting. The tracker was a URL-form island inside an RRI-form pipeline. Accept both canonical remote forms in the guard instead. A URL starts with `http://`/`https://` and a prefix-form RRI starts with `@`, which distinguishes them without a VirtualNetwork — the same syntactic test `isLocalId` uses — so the tracker stays free of realm mappings. Bare specifiers, relative references and other schemes are still dropped. That lets the conversions go: - `card-api` records `identity.module` and the loader's consumed-module list as they are, both already canonical RRI. - The loader's two internal tracking calls use `moduleCacheKey`, the same fold its module cache uses, so a module's dependency identity and its class identity are one key and cannot diverge. - `dependencyTrackingKey` and `canonicalizeTrackingKey` are removed. Deps on disk are unchanged: they were already normalized to prefix form by the index writer, which now finds them in that form to begin with. The `unresolveURLs` calls at the snapshot consumers are left in place — they also normalize deps from other sources merged into the same arrays. Co-Authored-By: Claude Opus 5 --- packages/base/card-api.gts | 17 ++-- .../tests/runtime-dependency-tracker-test.ts | 80 +++++++++++++++++++ packages/runtime-common/dependency-tracker.ts | 12 ++- packages/runtime-common/loader.ts | 52 +++--------- 4 files changed, 108 insertions(+), 53 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 2524d863f88..f36980add62 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -4076,24 +4076,17 @@ function trackRuntimeRelationshipModuleDependencies( return; } - // Loader identities and dependency lists are in canonical RRI form, while - // the dependency tracker keys module nodes by http(s) URL and drops - // anything else — so convert at this boundary via the loader's - // tracking-key form. + // Loader identities and dependency lists are already in canonical RRI form, + // which is what the tracker records — so they are passed through as they are. + trackRuntimeModuleDependency(identity.module, dependencyTrackingContext); + let loader = Loader.getLoaderFor(ctor); - trackRuntimeModuleDependency( - loader ? loader.dependencyTrackingKey(identity.module) : identity.module, - dependencyTrackingContext, - ); if (!loader) { return; } for (let dep of loader.getKnownConsumedModules(identity.module)) { - trackRuntimeModuleDependency( - loader.dependencyTrackingKey(dep), - dependencyTrackingContext, - ); + trackRuntimeModuleDependency(dep, dependencyTrackingContext); } } diff --git a/packages/realm-server/tests/runtime-dependency-tracker-test.ts b/packages/realm-server/tests/runtime-dependency-tracker-test.ts index 4e5f92a0db8..1a4dabc9b1a 100644 --- a/packages/realm-server/tests/runtime-dependency-tracker-test.ts +++ b/packages/realm-server/tests/runtime-dependency-tracker-test.ts @@ -809,4 +809,84 @@ module(basename(import.meta.filename), function (hooks) { 'module dep is recorded once across repeated imports', ); }); + + test('records prefix-form identifiers alongside URL-form ones', async function (assert) { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-prefix-form', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + + await withRuntimeDependencyTrackingContext( + { + mode: 'non-query', + source: 'test:prefix-form', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, + async () => { + // A module identifier, its executable extension stripped as for a URL. + trackRuntimeModuleDependency('@cardstack/base/card-api.gts'); + // An instance identifier, given the `.json` suffix the index carries. + trackRuntimeInstanceDependency('@cardstack/catalog/Author/mango'); + // A file identifier, kept verbatim. + trackRuntimeFileDependency('@cardstack/base/README.md'); + // Query strings and fragments are stripped in prefix form too. + trackRuntimeModuleDependency('@cardstack/base/pet?v=2#frag'); + }, + ); + + let { deps } = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + assert.true( + deps.includes('@cardstack/base/card-api'), + 'a prefix-form module dep is recorded with its extension stripped', + ); + assert.true( + deps.includes('@cardstack/catalog/Author/mango.json'), + 'a prefix-form instance dep is recorded in the form the index carries', + ); + assert.true( + deps.includes('@cardstack/base/README.md'), + 'a prefix-form file dep is recorded verbatim', + ); + assert.true( + deps.includes('@cardstack/base/pet'), + 'a prefix-form dep is recorded without its query string or fragment', + ); + }); + + test('drops identifiers that are neither a URL nor a prefix-form RRI', async function (assert) { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-junk', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + + await withRuntimeDependencyTrackingContext( + { + mode: 'non-query', + source: 'test:junk', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, + async () => { + // None of these name a resource the index can invalidate against, so + // recording them would put an edge in the graph that nothing satisfies. + for (let junk of [ + 'lodash-es', + './relative-module', + '../up-one', + '/root-relative', + 'data:text/javascript,export default 1', + 'blob:https://example.com/abc', + '', + ]) { + trackRuntimeModuleDependency(junk); + } + }, + ); + + let { deps } = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + assert.deepEqual(deps, [], 'no non-canonical identifier is recorded'); + }); }); diff --git a/packages/runtime-common/dependency-tracker.ts b/packages/runtime-common/dependency-tracker.ts index adce8836b72..d395bb8a8d6 100644 --- a/packages/runtime-common/dependency-tracker.ts +++ b/packages/runtime-common/dependency-tracker.ts @@ -44,8 +44,18 @@ interface ContextStackEntry { // These are called on every dependency tracking operation (field getter access) // so performance is critical. +// Accepts the two canonical remote forms and nothing else. A URL starts with +// `http://`/`https://` and a prefix-form RRI starts with `@`, which tells them +// apart without a VirtualNetwork — the same syntactic test `isLocalId` relies +// on. Everything else (bare specifiers, relative references, `data:`/`blob:` +// URLs) has no stable identity for a dependency edge and is dropped. function canonicalURL(url: string): string | undefined { - if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { + if ( + !url || + (!url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('@')) + ) { return undefined; } // Strip query string and hash using string ops instead of new URL() diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 1d186ed9c1c..8157f3dbe40 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -429,17 +429,14 @@ export class Loader { let resolvedModule = new URL(moduleIdentifier); let resolvedModuleIdentifier = resolvedModule.href; if (!this.moduleShims.has(resolvedModuleIdentifier)) { - // Normalize tracker keys to the virtual-alias URL form when one - // exists (the dependency tracker requires `http://`/`https://` - // URLs — see `canonicalURL` in dependency-tracker.ts — so RRI - // prefix forms can't be used as keys). Without this, a base - // module imported via the virtual alias - // (`https://cardstack.com/base/X`) and the same module imported - // via the RRI prefix (`@cardstack/base/X` → resolveImport → - // resolved real URL `https://localhost:4201/base/X`) get tracked - // as two separate entries. - let trackingKey = this.canonicalizeTrackingKey(resolvedModuleIdentifier); - trackRuntimeModuleDependency(trackingKey, dependencyTrackingContext); + // Tracked under the module cache's key, so a module reached by any of + // its spellings — virtual alias, resolved real URL, RRI prefix — is one + // node rather than several. Sharing the key with the cache means a + // module's dependency identity and its class identity can't diverge. + trackRuntimeModuleDependency( + this.moduleCacheKey(resolvedModuleIdentifier), + dependencyTrackingContext, + ); } await this.advanceToState(resolvedModule, 'evaluated'); @@ -546,18 +543,6 @@ export class Loader { return result; } - // The runtime dependency tracker keys module nodes by http(s) URL — its - // canonicalURL guard drops realm-prefix identifiers (see - // dependency-tracker.ts) — and this loader collapses each tracked module - // onto its virtual-alias URL when one is registered (see - // canonicalizeTrackingKey). Converts any module identifier, canonical RRI - // form included, into that tracking-key form. Callers recording - // loader-derived module identifiers with the tracker must cross this - // boundary; identifiers stay in canonical RRI form everywhere else. - dependencyTrackingKey(moduleIdentifier: string): string { - return this.canonicalizeTrackingKey(this.resolveImport(moduleIdentifier)); - } - private trackKnownModuleDependencies( rootModuleIdentifier: string, dependencyTrackingContext?: RuntimeDependencyTrackingContext, @@ -580,11 +565,10 @@ export class Loader { rootModuleIdentifier, )) { if (!this.moduleShims.has(moduleIdentifier)) { - // Same canonicalization as the top-level import-time tracking - // call — collapse virtual-alias / resolved real URL forms onto - // the virtual-alias URL. - let trackingKey = this.canonicalizeTrackingKey(moduleIdentifier); - trackRuntimeModuleDependency(trackingKey, dependencyTrackingContext); + trackRuntimeModuleDependency( + this.moduleCacheKey(moduleIdentifier), + dependencyTrackingContext, + ); } } } @@ -947,18 +931,6 @@ export class Loader { // virtual-alias (`https://cardstack.com/base/X`) and resolved real URL // (`https://localhost:4201/base/X`) for the same module. Returns the // input unchanged when no virtual alias is registered. - private canonicalizeTrackingKey(moduleIdentifier: string): string { - if (!this.virtualNetwork) { - return moduleIdentifier; - } - try { - let parsed = new URL(moduleIdentifier); - let virtual = this.virtualNetwork.mapURL(parsed, 'real-to-virtual'); - return virtual ? virtual.href : moduleIdentifier; - } catch { - return moduleIdentifier; - } - } private captureIdentitiesOfModuleExports( module: any, From 8ffeb9dc759b0b096e6e54af14f42758eaaadccf Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 13 Aug 2026 16:33:43 -0400 Subject: [PATCH 2/3] Keep url-mapped realms on their alias in tracking keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moduleCacheKey` folds a module onto its canonical RRI, which is right for a realm reached through a registered prefix but wrong for one that has only a URL mapping: `unresolveURL` maps an alias *to* the real URL and never back, so a module in such a realm was recorded under the host actually serving it rather than the alias the index names it by. In CI that meant a dep on `https://realm-test.ci.localhost/test/person` where every index row says `https://localhost:4202/test/person` — a dep no invalidation scan can match. Give tracking its own fold: canonical RRI when a prefix mapping claims the module, the virtual alias when only a URL mapping does. The module cache keeps `moduleCacheKey`, which needs internal consistency rather than agreement with the index, and the two requirements are not the same. Co-Authored-By: Claude Opus 5 --- packages/runtime-common/loader.ts | 39 ++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 8157f3dbe40..3878fe063a6 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -429,12 +429,10 @@ export class Loader { let resolvedModule = new URL(moduleIdentifier); let resolvedModuleIdentifier = resolvedModule.href; if (!this.moduleShims.has(resolvedModuleIdentifier)) { - // Tracked under the module cache's key, so a module reached by any of - // its spellings — virtual alias, resolved real URL, RRI prefix — is one - // node rather than several. Sharing the key with the cache means a - // module's dependency identity and its class identity can't diverge. + // Tracked under the form the index names this module's realm by, so a + // module reached by any of its spellings is one node rather than several. trackRuntimeModuleDependency( - this.moduleCacheKey(resolvedModuleIdentifier), + this.trackingKey(resolvedModuleIdentifier), dependencyTrackingContext, ); } @@ -566,7 +564,7 @@ export class Loader { )) { if (!this.moduleShims.has(moduleIdentifier)) { trackRuntimeModuleDependency( - this.moduleCacheKey(moduleIdentifier), + this.trackingKey(moduleIdentifier), dependencyTrackingContext, ); } @@ -904,6 +902,35 @@ export class Loader { : trimmed; } + // The key a module is recorded under with the dependency tracker: the form + // the index carries for that module's realm, so a dep here matches the rows + // invalidation searches. + // + // A realm reached through a registered prefix is named by its RRI. A realm + // that has only a URL mapping — the alias a test or deployment serves it + // under — is named by that alias, not by the host actually serving it. + // `unresolveURL` covers the first and leaves the second alone (it maps an + // alias *to* the real URL, never back), so the alias case needs its own fold. + private trackingKey(moduleIdentifier: string): string { + let trimmed = trimModuleIdentifier(moduleIdentifier); + if (!this.virtualNetwork) { + return trimmed; + } + let unresolved = this.virtualNetwork.unresolveURL(trimmed); + if (unresolved !== trimmed) { + return unresolved; + } + try { + let virtual = this.virtualNetwork.mapURL(trimmed, 'real-to-virtual'); + if (virtual) { + return virtual.href; + } + } catch { + // Not a parseable URL — a prefix-form identifier, already canonical. + } + return trimmed; + } + private getModule(moduleIdentifier: string): Module | undefined { return this.modules.get(this.moduleCacheKey(moduleIdentifier)); } From 359b9a1727629cdc2d9ae571facf5db8fed5c2bd Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 08:53:55 -0400 Subject: [PATCH 3/3] Memoize the dependency tracking key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency walk re-derives a tracking key for every module in a root's transitive set on every import of that root, so a realm's modules are folded repeatedly during a from-scratch index — and the url-mapped branch allocates a URL each time. Cache the result per module identifier. Cleared alongside the other mapping-derived caches when a realm mapping is added or removed, since the key is only stable between those changes. Co-Authored-By: Claude Opus 5 --- packages/runtime-common/loader.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 3878fe063a6..4ef0707cf04 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -201,6 +201,8 @@ export class Loader { // collectKnownModuleDependencies is stable and can be reused across repeated // loader.import() calls (e.g. when deserializing 22 cards of the same type). private knownDepsCache = new Map>(); + // Module identifier → the key it is tracked under (see trackingKey). + private trackingKeyCache = new Map(); private identities = new WeakMap< Function, { module: string; name: string } @@ -246,6 +248,7 @@ export class Loader { this.modules.clear(); this.moduleCanonicalURLs.clear(); this.knownDepsCache.clear(); + this.trackingKeyCache.clear(); }); } @@ -911,7 +914,22 @@ export class Loader { // under — is named by that alias, not by the host actually serving it. // `unresolveURL` covers the first and leaves the second alone (it maps an // alias *to* the real URL, never back), so the alias case needs its own fold. + // Memoized because the dependency walk re-derives the key for every module in + // a root's transitive set on every import of that root, and the alias branch + // below allocates a URL. Keyed by the raw identifier so the trim is memoized + // too. Discarded with the other mapping-derived caches when a realm mapping + // changes, since the key it produces is only stable between those changes. private trackingKey(moduleIdentifier: string): string { + let cached = this.trackingKeyCache.get(moduleIdentifier); + if (cached !== undefined) { + return cached; + } + let key = this.computeTrackingKey(moduleIdentifier); + this.trackingKeyCache.set(moduleIdentifier, key); + return key; + } + + private computeTrackingKey(moduleIdentifier: string): string { let trimmed = trimModuleIdentifier(moduleIdentifier); if (!this.virtualNetwork) { return trimmed;