Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
80 changes: 80 additions & 0 deletions packages/realm-server/tests/runtime-dependency-tracker-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 11 additions & 1 deletion packages/runtime-common/dependency-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
97 changes: 57 additions & 40 deletions packages/runtime-common/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>();
// Module identifier → the key it is tracked under (see trackingKey).
private trackingKeyCache = new Map<string, string>();
private identities = new WeakMap<
Function,
{ module: string; name: string }
Expand Down Expand Up @@ -246,6 +248,7 @@ export class Loader {
this.modules.clear();
this.moduleCanonicalURLs.clear();
this.knownDepsCache.clear();
this.trackingKeyCache.clear();
});
}

Expand Down Expand Up @@ -429,17 +432,12 @@ 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 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.trackingKey(resolvedModuleIdentifier),
dependencyTrackingContext,
);
}

await this.advanceToState(resolvedModule, 'evaluated');
Expand Down Expand Up @@ -546,18 +544,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,
Expand All @@ -580,11 +566,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.trackingKey(moduleIdentifier),
dependencyTrackingContext,
);
}
}
}
Expand Down Expand Up @@ -920,6 +905,50 @@ 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.
// 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;
}
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));
}
Expand Down Expand Up @@ -947,18 +976,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,
Expand Down
Loading