capdag 0.148.329 → 0.152.345
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/capdag.js +234 -104
- package/capdag.test.js +199 -48
- package/package.json +2 -2
package/capdag.js
CHANGED
|
@@ -3634,6 +3634,12 @@ class CartridgeInfo {
|
|
|
3634
3634
|
// Versions with platform-specific builds
|
|
3635
3635
|
this.versions = data.versions || {};
|
|
3636
3636
|
this.availableVersions = data.availableVersions || [];
|
|
3637
|
+
// Channel: 'release' or 'nightly'. Required — set by the registry
|
|
3638
|
+
// transformer when flattening the channel-partitioned registry.
|
|
3639
|
+
if (data.channel !== 'release' && data.channel !== 'nightly') {
|
|
3640
|
+
throw new Error(`CartridgeInfo ${data.id || '?'}: invalid or missing channel '${data.channel}'`);
|
|
3641
|
+
}
|
|
3642
|
+
this.channel = data.channel;
|
|
3637
3643
|
}
|
|
3638
3644
|
|
|
3639
3645
|
/** All caps flattened across all cap_groups, deduplicated by URN */
|
|
@@ -3682,7 +3688,9 @@ class CartridgeInfo {
|
|
|
3682
3688
|
}
|
|
3683
3689
|
|
|
3684
3690
|
/**
|
|
3685
|
-
* Cartridge suggestion for a missing cap
|
|
3691
|
+
* Cartridge suggestion for a missing cap. `channel` reports which
|
|
3692
|
+
* channel the suggesting cartridge lives in so consumers can render
|
|
3693
|
+
* the release/nightly distinction.
|
|
3686
3694
|
*/
|
|
3687
3695
|
class CartridgeSuggestion {
|
|
3688
3696
|
constructor(data) {
|
|
@@ -3694,21 +3702,31 @@ class CartridgeSuggestion {
|
|
|
3694
3702
|
this.latestVersion = data.latestVersion;
|
|
3695
3703
|
this.repoUrl = data.repoUrl;
|
|
3696
3704
|
this.pageUrl = data.pageUrl;
|
|
3705
|
+
if (data.channel !== 'release' && data.channel !== 'nightly') {
|
|
3706
|
+
throw new Error(`CartridgeSuggestion: invalid or missing channel '${data.channel}'`);
|
|
3707
|
+
}
|
|
3708
|
+
this.channel = data.channel;
|
|
3697
3709
|
}
|
|
3698
3710
|
}
|
|
3699
3711
|
|
|
3700
3712
|
/**
|
|
3701
|
-
* Cartridge registry cache entry
|
|
3713
|
+
* Cartridge registry cache entry. The cartridges map is keyed by
|
|
3714
|
+
* `<channel>:<id>` so the same id can independently coexist in both
|
|
3715
|
+
* channels.
|
|
3702
3716
|
*/
|
|
3703
3717
|
class CartridgeRepoCache {
|
|
3704
3718
|
constructor(repoUrl) {
|
|
3705
|
-
this.cartridges = new Map(); //
|
|
3706
|
-
this.capToCartridges = new Map(); // cap_urn -> [
|
|
3719
|
+
this.cartridges = new Map(); // "<channel>:<id>" -> CartridgeInfo
|
|
3720
|
+
this.capToCartridges = new Map(); // cap_urn -> [{channel, id}]
|
|
3707
3721
|
this.lastUpdated = Date.now();
|
|
3708
3722
|
this.repoUrl = repoUrl;
|
|
3709
3723
|
}
|
|
3710
3724
|
}
|
|
3711
3725
|
|
|
3726
|
+
function _cacheKey(channel, id) {
|
|
3727
|
+
return `${channel}:${id}`;
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3712
3730
|
/**
|
|
3713
3731
|
* Cartridge repository client - fetches and caches cartridge registry
|
|
3714
3732
|
*/
|
|
@@ -3719,43 +3737,74 @@ class CartridgeRepoClient {
|
|
|
3719
3737
|
}
|
|
3720
3738
|
|
|
3721
3739
|
/**
|
|
3722
|
-
* Fetch registry from a URL
|
|
3740
|
+
* Fetch a v5.0 channel-partitioned registry from a URL and flatten
|
|
3741
|
+
* to a list of `CartridgeInfo`, one per `(channel, id)` pair.
|
|
3723
3742
|
*/
|
|
3724
3743
|
async fetchRegistry(repoUrl) {
|
|
3725
3744
|
const response = await fetch(repoUrl);
|
|
3726
|
-
|
|
3745
|
+
if (response.status === 404) {
|
|
3746
|
+
// Manifest not published yet — return an empty list so the
|
|
3747
|
+
// caller's cache reflects "no cartridges available" without
|
|
3748
|
+
// poisoning future syncs.
|
|
3749
|
+
return [];
|
|
3750
|
+
}
|
|
3727
3751
|
if (!response.ok) {
|
|
3728
3752
|
throw new Error(`Cartridge registry request failed: HTTP ${response.status} from ${repoUrl}`);
|
|
3729
3753
|
}
|
|
3730
3754
|
|
|
3731
3755
|
const data = await response.json();
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3756
|
+
if (data.schemaVersion !== '5.0') {
|
|
3757
|
+
throw new Error(`Cartridge registry from ${repoUrl} has schemaVersion '${data.schemaVersion}'; required: 5.0`);
|
|
3758
|
+
}
|
|
3759
|
+
if (!data.channels || typeof data.channels !== 'object') {
|
|
3760
|
+
throw new Error(`Cartridge registry from ${repoUrl}: missing channels object`);
|
|
3735
3761
|
}
|
|
3736
3762
|
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3763
|
+
const out = [];
|
|
3764
|
+
for (const channel of ['release', 'nightly']) {
|
|
3765
|
+
const entry = data.channels[channel];
|
|
3766
|
+
if (!entry || typeof entry !== 'object' || !entry.cartridges || typeof entry.cartridges !== 'object') {
|
|
3767
|
+
throw new Error(`Cartridge registry from ${repoUrl}: channels.${channel}.cartridges must be an object`);
|
|
3768
|
+
}
|
|
3769
|
+
for (const [id, c] of Object.entries(entry.cartridges)) {
|
|
3770
|
+
out.push(new CartridgeInfo({
|
|
3771
|
+
...c,
|
|
3772
|
+
id,
|
|
3773
|
+
version: c.latestVersion,
|
|
3774
|
+
channel
|
|
3775
|
+
}));
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
return out;
|
|
3743
3779
|
}
|
|
3744
3780
|
|
|
3745
3781
|
/**
|
|
3746
|
-
* Update cache from registry data
|
|
3782
|
+
* Update cache from registry data.
|
|
3783
|
+
*
|
|
3784
|
+
* The cartridges map is keyed by `<channel>:<id>` so the same id can
|
|
3785
|
+
* coexist in release and nightly with separate metadata/versions. The
|
|
3786
|
+
* cap-to-cartridges index keys on the *normalized* tagged-URN form
|
|
3787
|
+
* (parse via CapUrn.fromString, then take toString()) and stores
|
|
3788
|
+
* `{channel, id}` references so suggestions preserve channel
|
|
3789
|
+
* provenance. A cap URN that fails to parse is a registry corruption:
|
|
3790
|
+
* we throw rather than silently keep the malformed string in the
|
|
3791
|
+
* index.
|
|
3747
3792
|
*/
|
|
3748
3793
|
updateCache(repoUrl, cartridges) {
|
|
3749
3794
|
const cache = new CartridgeRepoCache(repoUrl);
|
|
3750
3795
|
|
|
3751
3796
|
for (const cartridge of cartridges) {
|
|
3752
|
-
cache.cartridges.set(cartridge.id, cartridge);
|
|
3797
|
+
cache.cartridges.set(_cacheKey(cartridge.channel, cartridge.id), cartridge);
|
|
3753
3798
|
|
|
3754
3799
|
for (const cap of cartridge.allCaps()) {
|
|
3755
|
-
|
|
3756
|
-
|
|
3800
|
+
const normalized = CapUrn.fromString(cap.urn).toString();
|
|
3801
|
+
if (!cache.capToCartridges.has(normalized)) {
|
|
3802
|
+
cache.capToCartridges.set(normalized, []);
|
|
3757
3803
|
}
|
|
3758
|
-
cache.capToCartridges.get(
|
|
3804
|
+
cache.capToCartridges.get(normalized).push({
|
|
3805
|
+
channel: cartridge.channel,
|
|
3806
|
+
id: cartridge.id
|
|
3807
|
+
});
|
|
3759
3808
|
}
|
|
3760
3809
|
}
|
|
3761
3810
|
|
|
@@ -3798,20 +3847,37 @@ class CartridgeRepoClient {
|
|
|
3798
3847
|
}
|
|
3799
3848
|
|
|
3800
3849
|
/**
|
|
3801
|
-
* Get cartridge suggestions for a cap URN
|
|
3850
|
+
* Get cartridge suggestions for a cap URN.
|
|
3851
|
+
*
|
|
3852
|
+
* `capUrn` is parsed via CapUrn.fromString; the parsed-and-
|
|
3853
|
+
* re-serialized form is the canonical key into the cap-to-cartridges
|
|
3854
|
+
* index. Inside each candidate cartridge we walk its caps via
|
|
3855
|
+
* `allCaps()` and match each one with `isEquivalent`. The `op` tag
|
|
3856
|
+
* has no functional role — only `in` and `out` predicates participate
|
|
3857
|
+
* in dispatch.
|
|
3802
3858
|
*/
|
|
3803
3859
|
getSuggestionsForCap(capUrn) {
|
|
3860
|
+
const requested = CapUrn.fromString(capUrn);
|
|
3861
|
+
const normalized = requested.toString();
|
|
3804
3862
|
const suggestions = [];
|
|
3805
3863
|
|
|
3806
3864
|
for (const cache of this.caches.values()) {
|
|
3807
|
-
const
|
|
3808
|
-
if (!
|
|
3865
|
+
const refs = cache.capToCartridges.get(normalized);
|
|
3866
|
+
if (!refs) continue;
|
|
3809
3867
|
|
|
3810
|
-
for (const
|
|
3811
|
-
const cartridge = cache.cartridges.get(
|
|
3868
|
+
for (const ref of refs) {
|
|
3869
|
+
const cartridge = cache.cartridges.get(_cacheKey(ref.channel, ref.id));
|
|
3812
3870
|
if (!cartridge) continue;
|
|
3813
3871
|
|
|
3814
|
-
const capInfo = cartridge.allCaps().find(c =>
|
|
3872
|
+
const capInfo = cartridge.allCaps().find(c => {
|
|
3873
|
+
let parsed;
|
|
3874
|
+
try {
|
|
3875
|
+
parsed = CapUrn.fromString(c.urn);
|
|
3876
|
+
} catch (_e) {
|
|
3877
|
+
return false;
|
|
3878
|
+
}
|
|
3879
|
+
return parsed.isEquivalent(requested);
|
|
3880
|
+
});
|
|
3815
3881
|
if (!capInfo) continue;
|
|
3816
3882
|
|
|
3817
3883
|
const pageUrl = cartridge.pageUrl || cache.repoUrl;
|
|
@@ -3820,11 +3886,12 @@ class CartridgeRepoClient {
|
|
|
3820
3886
|
cartridgeId: cartridge.id,
|
|
3821
3887
|
cartridgeName: cartridge.name,
|
|
3822
3888
|
cartridgeDescription: cartridge.description,
|
|
3823
|
-
capUrn:
|
|
3889
|
+
capUrn: normalized,
|
|
3824
3890
|
capTitle: capInfo.title,
|
|
3825
3891
|
latestVersion: cartridge.version,
|
|
3826
3892
|
repoUrl: cache.repoUrl,
|
|
3827
|
-
pageUrl: pageUrl
|
|
3893
|
+
pageUrl: pageUrl,
|
|
3894
|
+
channel: cartridge.channel
|
|
3828
3895
|
}));
|
|
3829
3896
|
}
|
|
3830
3897
|
}
|
|
@@ -3833,13 +3900,15 @@ class CartridgeRepoClient {
|
|
|
3833
3900
|
}
|
|
3834
3901
|
|
|
3835
3902
|
/**
|
|
3836
|
-
* Get all available cartridges from all repos
|
|
3903
|
+
* Get all available cartridges from all repos as
|
|
3904
|
+
* `[channel, id, cartridgeInfo]` tuples — the channel is first-class
|
|
3905
|
+
* so consumers don't have to look it up separately.
|
|
3837
3906
|
*/
|
|
3838
3907
|
getAllCartridges() {
|
|
3839
3908
|
const cartridges = [];
|
|
3840
3909
|
for (const cache of this.caches.values()) {
|
|
3841
|
-
for (const
|
|
3842
|
-
cartridges.push([
|
|
3910
|
+
for (const cartridgeInfo of cache.cartridges.values()) {
|
|
3911
|
+
cartridges.push([cartridgeInfo.channel, cartridgeInfo.id, cartridgeInfo]);
|
|
3843
3912
|
}
|
|
3844
3913
|
}
|
|
3845
3914
|
return cartridges;
|
|
@@ -3859,11 +3928,17 @@ class CartridgeRepoClient {
|
|
|
3859
3928
|
}
|
|
3860
3929
|
|
|
3861
3930
|
/**
|
|
3862
|
-
* Get cartridge info by
|
|
3931
|
+
* Get cartridge info by `(channel, id)`. Channel is required —
|
|
3932
|
+
* the same id can independently exist in both channels with
|
|
3933
|
+
* different metadata. Returns `null` when not found.
|
|
3863
3934
|
*/
|
|
3864
|
-
getCartridge(cartridgeId) {
|
|
3935
|
+
getCartridge(channel, cartridgeId) {
|
|
3936
|
+
if (channel !== 'release' && channel !== 'nightly') {
|
|
3937
|
+
throw new Error(`Invalid channel '${channel}' — must be 'release' or 'nightly'`);
|
|
3938
|
+
}
|
|
3939
|
+
const key = _cacheKey(channel, cartridgeId);
|
|
3865
3940
|
for (const cache of this.caches.values()) {
|
|
3866
|
-
const cartridge = cache.cartridges.get(
|
|
3941
|
+
const cartridge = cache.cartridges.get(key);
|
|
3867
3942
|
if (cartridge) {
|
|
3868
3943
|
return cartridge;
|
|
3869
3944
|
}
|
|
@@ -3891,51 +3966,74 @@ class CartridgeRepoClient {
|
|
|
3891
3966
|
/**
|
|
3892
3967
|
* Cartridge repository server - serves registry data with queries
|
|
3893
3968
|
*/
|
|
3969
|
+
/**
|
|
3970
|
+
* Distribution channel for a cartridge entry — mirrors capdag's
|
|
3971
|
+
* `CartridgeChannel` and the registry's `channels.<channel>` keys.
|
|
3972
|
+
*
|
|
3973
|
+
* `release` is the user-facing channel; `nightly` is the in-flight
|
|
3974
|
+
* channel. Always one of these two strings — no other values are valid.
|
|
3975
|
+
*/
|
|
3976
|
+
const CartridgeChannel = Object.freeze({
|
|
3977
|
+
Release: 'release',
|
|
3978
|
+
Nightly: 'nightly'
|
|
3979
|
+
});
|
|
3980
|
+
|
|
3981
|
+
function _validChannel(c) {
|
|
3982
|
+
return c === CartridgeChannel.Release || c === CartridgeChannel.Nightly;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
/**
|
|
3986
|
+
* Reads a v5.0 channel-partitioned cartridge registry. Both `release`
|
|
3987
|
+
* and `nightly` channels are always present (possibly empty); every
|
|
3988
|
+
* `CartridgeInfo` returned carries the channel it came from so consumers
|
|
3989
|
+
* can render the release/nightly distinction without re-deriving.
|
|
3990
|
+
*/
|
|
3894
3991
|
class CartridgeRepoServer {
|
|
3895
3992
|
constructor(registry) {
|
|
3896
3993
|
this.registry = registry;
|
|
3897
3994
|
this.validateRegistry();
|
|
3898
3995
|
}
|
|
3899
3996
|
|
|
3900
|
-
/**
|
|
3901
|
-
* Validate registry schema
|
|
3902
|
-
*/
|
|
3903
3997
|
validateRegistry() {
|
|
3904
3998
|
if (!this.registry) {
|
|
3905
3999
|
throw new Error('Registry is required');
|
|
3906
4000
|
}
|
|
3907
|
-
if (this.registry.schemaVersion !== '
|
|
3908
|
-
throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required:
|
|
4001
|
+
if (this.registry.schemaVersion !== '5.0') {
|
|
4002
|
+
throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 5.0`);
|
|
3909
4003
|
}
|
|
3910
|
-
|
|
3911
|
-
|
|
4004
|
+
const channels = this.registry.channels;
|
|
4005
|
+
if (!channels || typeof channels !== 'object') {
|
|
4006
|
+
throw new Error('Registry must have a channels object');
|
|
4007
|
+
}
|
|
4008
|
+
for (const ch of [CartridgeChannel.Release, CartridgeChannel.Nightly]) {
|
|
4009
|
+
const entry = channels[ch];
|
|
4010
|
+
if (!entry || typeof entry !== 'object') {
|
|
4011
|
+
throw new Error(`Registry must have channels.${ch}`);
|
|
4012
|
+
}
|
|
4013
|
+
if (!entry.cartridges || typeof entry.cartridges !== 'object') {
|
|
4014
|
+
throw new Error(`Registry: channels.${ch}.cartridges must be an object`);
|
|
4015
|
+
}
|
|
3912
4016
|
}
|
|
3913
4017
|
}
|
|
3914
4018
|
|
|
3915
|
-
|
|
3916
|
-
* Validate version data has all required fields
|
|
3917
|
-
*/
|
|
3918
|
-
validateVersionData(id, version, versionData) {
|
|
4019
|
+
validateVersionData(channel, id, version, versionData) {
|
|
3919
4020
|
if (!Array.isArray(versionData.builds) || versionData.builds.length === 0) {
|
|
3920
|
-
throw new Error(`Cartridge ${id} v${version}: no builds`);
|
|
4021
|
+
throw new Error(`Cartridge ${id} (${channel}) v${version}: no builds`);
|
|
3921
4022
|
}
|
|
3922
4023
|
for (let i = 0; i < versionData.builds.length; i++) {
|
|
3923
4024
|
const build = versionData.builds[i];
|
|
3924
4025
|
if (!build.platform) {
|
|
3925
|
-
throw new Error(`Cartridge ${id} v${version}: build[${i}] missing platform`);
|
|
4026
|
+
throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] missing platform`);
|
|
3926
4027
|
}
|
|
3927
4028
|
if (!build.package || !build.package.name) {
|
|
3928
|
-
throw new Error(`Cartridge ${id} v${version}: build[${i}] (${build.platform}) missing package.name`);
|
|
4029
|
+
throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] (${build.platform}) missing package.name`);
|
|
3929
4030
|
}
|
|
3930
4031
|
if (!build.package.url) {
|
|
3931
|
-
throw new Error(`Cartridge ${id} v${version}: build[${i}] (${build.platform}) missing package.url`);
|
|
4032
|
+
throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] (${build.platform}) missing package.url`);
|
|
3932
4033
|
}
|
|
3933
4034
|
}
|
|
3934
4035
|
}
|
|
3935
4036
|
|
|
3936
|
-
/**
|
|
3937
|
-
* Compare version strings
|
|
3938
|
-
*/
|
|
3939
4037
|
compareVersions(a, b) {
|
|
3940
4038
|
const partsA = a.split('.').map(x => parseInt(x) || 0);
|
|
3941
4039
|
const partsB = b.split('.').map(x => parseInt(x) || 0);
|
|
@@ -3951,87 +4049,103 @@ class CartridgeRepoServer {
|
|
|
3951
4049
|
}
|
|
3952
4050
|
|
|
3953
4051
|
/**
|
|
3954
|
-
*
|
|
4052
|
+
* Convert one channel-entry into a flat CartridgeInfo. Throws if the
|
|
4053
|
+
* entry's `latestVersion` is not present in `versions` or if the
|
|
4054
|
+
* latest version's builds are malformed.
|
|
3955
4055
|
*/
|
|
3956
|
-
|
|
3957
|
-
const
|
|
3958
|
-
const
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
4056
|
+
_entryToCartridgeInfo(channel, id, cartridge) {
|
|
4057
|
+
const latestVersion = cartridge.latestVersion;
|
|
4058
|
+
const versionData = cartridge.versions[latestVersion];
|
|
4059
|
+
if (!versionData) {
|
|
4060
|
+
throw new Error(`Cartridge ${id} (${channel}): latestVersion ${latestVersion} not found in versions`);
|
|
4061
|
+
}
|
|
4062
|
+
this.validateVersionData(channel, id, latestVersion, versionData);
|
|
3963
4063
|
|
|
3964
|
-
|
|
3965
|
-
throw new Error(`Cartridge ${id}: latest version ${latestVersion} not found in versions`);
|
|
3966
|
-
}
|
|
4064
|
+
const availableVersions = Object.keys(cartridge.versions).sort((a, b) => this.compareVersions(b, a));
|
|
3967
4065
|
|
|
3968
|
-
|
|
3969
|
-
|
|
4066
|
+
if (!Array.isArray(cartridge.cap_groups)) {
|
|
4067
|
+
throw new Error(`Cartridge ${id} (${channel}): missing cap_groups array`);
|
|
4068
|
+
}
|
|
3970
4069
|
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
4070
|
+
return {
|
|
4071
|
+
id,
|
|
4072
|
+
name: cartridge.name,
|
|
4073
|
+
version: latestVersion,
|
|
4074
|
+
description: cartridge.description,
|
|
4075
|
+
author: cartridge.author,
|
|
4076
|
+
pageUrl: cartridge.pageUrl || '',
|
|
4077
|
+
teamId: cartridge.teamId,
|
|
4078
|
+
signedAt: versionData.releaseDate,
|
|
4079
|
+
minAppVersion: versionData.minAppVersion || cartridge.minAppVersion,
|
|
4080
|
+
cap_groups: cartridge.cap_groups,
|
|
4081
|
+
categories: cartridge.categories,
|
|
4082
|
+
tags: cartridge.tags,
|
|
4083
|
+
versions: cartridge.versions,
|
|
4084
|
+
availableVersions,
|
|
4085
|
+
channel
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
3975
4088
|
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
return cartridge.cap_groups;
|
|
3989
|
-
})(),
|
|
3990
|
-
categories: cartridge.categories,
|
|
3991
|
-
tags: cartridge.tags,
|
|
3992
|
-
versions: cartridge.versions,
|
|
3993
|
-
availableVersions
|
|
3994
|
-
});
|
|
4089
|
+
/**
|
|
4090
|
+
* Walk both channels and emit a flat array of CartridgeInfo. Release
|
|
4091
|
+
* entries appear before nightly entries — UIs that paint in array
|
|
4092
|
+
* order get the user-facing channel at the top by default.
|
|
4093
|
+
*/
|
|
4094
|
+
transformToCartridgeArray() {
|
|
4095
|
+
const out = [];
|
|
4096
|
+
for (const channel of [CartridgeChannel.Release, CartridgeChannel.Nightly]) {
|
|
4097
|
+
const map = (this.registry.channels[channel].cartridges) || {};
|
|
4098
|
+
for (const [id, cartridge] of Object.entries(map)) {
|
|
4099
|
+
out.push(this._entryToCartridgeInfo(channel, id, cartridge));
|
|
4100
|
+
}
|
|
3995
4101
|
}
|
|
3996
|
-
|
|
3997
|
-
return cartridges;
|
|
4102
|
+
return out;
|
|
3998
4103
|
}
|
|
3999
4104
|
|
|
4000
4105
|
/**
|
|
4001
|
-
* Get all cartridges (API response format)
|
|
4106
|
+
* Get all cartridges (API response format) — both channels.
|
|
4002
4107
|
*/
|
|
4003
4108
|
getCartridges() {
|
|
4004
|
-
return {
|
|
4005
|
-
cartridges: this.transformToCartridgeArray()
|
|
4006
|
-
};
|
|
4109
|
+
return { cartridges: this.transformToCartridgeArray() };
|
|
4007
4110
|
}
|
|
4008
4111
|
|
|
4009
4112
|
/**
|
|
4010
|
-
* Get cartridge by
|
|
4113
|
+
* Get cartridge by `(channel, id)`. Channel is required because the
|
|
4114
|
+
* same id can independently exist in both channels. Returns
|
|
4115
|
+
* `undefined` if the cartridge isn't in the requested channel.
|
|
4011
4116
|
*/
|
|
4012
|
-
getCartridgeById(id) {
|
|
4013
|
-
|
|
4014
|
-
|
|
4117
|
+
getCartridgeById(channel, id) {
|
|
4118
|
+
if (!_validChannel(channel)) {
|
|
4119
|
+
throw new Error(`Invalid channel '${channel}' — must be 'release' or 'nightly'`);
|
|
4120
|
+
}
|
|
4121
|
+
const cartridge = this.registry.channels[channel].cartridges[id];
|
|
4122
|
+
if (!cartridge) return undefined;
|
|
4123
|
+
return this._entryToCartridgeInfo(channel, id, cartridge);
|
|
4015
4124
|
}
|
|
4016
4125
|
|
|
4017
4126
|
/**
|
|
4018
|
-
* Search cartridges by query
|
|
4127
|
+
* Search cartridges by free-text query across both channels.
|
|
4128
|
+
*
|
|
4129
|
+
* Matches against cartridge name, description, tags, and cap titles.
|
|
4130
|
+
* Cap URN strings are not substring-matched: a cap URN is a tagged
|
|
4131
|
+
* identifier and substring matching against it is a category error.
|
|
4132
|
+
* Use `getCartridgesByCap` to look up cartridges that provide a
|
|
4133
|
+
* specific cap.
|
|
4019
4134
|
*/
|
|
4020
4135
|
searchCartridges(query) {
|
|
4021
4136
|
const cartridges = this.transformToCartridgeArray();
|
|
4022
4137
|
const lowerQuery = query.toLowerCase();
|
|
4023
|
-
|
|
4024
4138
|
return cartridges.filter(p => {
|
|
4025
4139
|
const allCaps = (p.cap_groups || []).flatMap(g => g.caps || []);
|
|
4026
4140
|
return p.name.toLowerCase().includes(lowerQuery) ||
|
|
4027
4141
|
p.description.toLowerCase().includes(lowerQuery) ||
|
|
4028
4142
|
p.tags.some(t => t.toLowerCase().includes(lowerQuery)) ||
|
|
4029
|
-
allCaps.some(c => c.
|
|
4143
|
+
allCaps.some(c => c.title.toLowerCase().includes(lowerQuery));
|
|
4030
4144
|
});
|
|
4031
4145
|
}
|
|
4032
4146
|
|
|
4033
4147
|
/**
|
|
4034
|
-
* Get cartridges by category
|
|
4148
|
+
* Get cartridges by category — both channels.
|
|
4035
4149
|
*/
|
|
4036
4150
|
getCartridgesByCategory(category) {
|
|
4037
4151
|
const cartridges = this.transformToCartridgeArray();
|
|
@@ -4039,12 +4153,27 @@ class CartridgeRepoServer {
|
|
|
4039
4153
|
}
|
|
4040
4154
|
|
|
4041
4155
|
/**
|
|
4042
|
-
* Get cartridges that provide a specific cap
|
|
4156
|
+
* Get cartridges that provide a specific cap.
|
|
4157
|
+
*
|
|
4158
|
+
* The request URN is parsed via CapUrn.fromString. Each declared
|
|
4159
|
+
* cartridge cap is parsed and matched with `conformsTo`: cap dispatch
|
|
4160
|
+
* is the partial-order question "does the declared cap conform to
|
|
4161
|
+
* (i.e. refine, equal, or be more specific than) the requested
|
|
4162
|
+
* pattern?". Only `in` and `out` tags are semantically meaningful —
|
|
4163
|
+
* no string comparison, no special role for the `op` tag. A malformed
|
|
4164
|
+
* input URN throws; a malformed declared URN in the registry also
|
|
4165
|
+
* throws (registry corruption is not a fallback condition).
|
|
4043
4166
|
*/
|
|
4044
4167
|
getCartridgesByCap(capUrn) {
|
|
4168
|
+
const requested = CapUrn.fromString(capUrn);
|
|
4045
4169
|
const cartridges = this.transformToCartridgeArray();
|
|
4046
4170
|
return cartridges.filter(p =>
|
|
4047
|
-
(p.cap_groups || []).some(g =>
|
|
4171
|
+
(p.cap_groups || []).some(g =>
|
|
4172
|
+
(g.caps || []).some(c => {
|
|
4173
|
+
const declared = CapUrn.fromString(c.urn);
|
|
4174
|
+
return declared.conformsTo(requested);
|
|
4175
|
+
})
|
|
4176
|
+
)
|
|
4048
4177
|
);
|
|
4049
4178
|
}
|
|
4050
4179
|
}
|
|
@@ -5279,6 +5408,7 @@ module.exports = {
|
|
|
5279
5408
|
CartridgeRepoCache,
|
|
5280
5409
|
CartridgeRepoClient,
|
|
5281
5410
|
CartridgeRepoServer,
|
|
5411
|
+
CartridgeChannel,
|
|
5282
5412
|
// Machine notation
|
|
5283
5413
|
MachineSyntaxError,
|
|
5284
5414
|
MachineSyntaxErrorCodes,
|
package/capdag.test.js
CHANGED
|
@@ -1637,11 +1637,17 @@ function testJS_mediaSpecConstruction() {
|
|
|
1637
1637
|
// Cartridge Repository Tests (TEST320-TEST335)
|
|
1638
1638
|
// =============================================================================
|
|
1639
1639
|
|
|
1640
|
-
// Sample registry for testing
|
|
1640
|
+
// Sample registry for testing — v5.0 channel-partitioned schema.
|
|
1641
|
+
//
|
|
1642
|
+
// Both `release` and `nightly` are always present. We populate the
|
|
1643
|
+
// release channel with two cartridges (pdf + txt) for the bulk of the
|
|
1644
|
+
// tests, and add one nightly entry so isolation tests have something
|
|
1645
|
+
// to assert on. Tests that need an empty channel use literals inline.
|
|
1641
1646
|
const sampleRegistry = {
|
|
1642
|
-
schemaVersion: '
|
|
1647
|
+
schemaVersion: '5.0',
|
|
1643
1648
|
lastUpdated: '2026-02-07T16:48:28Z',
|
|
1644
|
-
|
|
1649
|
+
channels: {
|
|
1650
|
+
release: { cartridges: {
|
|
1645
1651
|
pdfcartridge: {
|
|
1646
1652
|
name: 'pdfcartridge',
|
|
1647
1653
|
description: 'PDF document processor',
|
|
@@ -1727,6 +1733,50 @@ const sampleRegistry = {
|
|
|
1727
1733
|
}
|
|
1728
1734
|
}
|
|
1729
1735
|
}
|
|
1736
|
+
} },
|
|
1737
|
+
nightly: { cartridges: {
|
|
1738
|
+
jsoncartridge: {
|
|
1739
|
+
name: 'jsoncartridge',
|
|
1740
|
+
description: 'JSON tooling — nightly only',
|
|
1741
|
+
author: 'test-author',
|
|
1742
|
+
pageUrl: 'https://example.com/json',
|
|
1743
|
+
teamId: 'P336JK947M',
|
|
1744
|
+
minAppVersion: '1.0.0',
|
|
1745
|
+
categories: ['data'],
|
|
1746
|
+
tags: ['json'],
|
|
1747
|
+
cap_groups: [
|
|
1748
|
+
{
|
|
1749
|
+
name: 'json-processing',
|
|
1750
|
+
adapter_urns: ['media:json'],
|
|
1751
|
+
caps: [
|
|
1752
|
+
{
|
|
1753
|
+
urn: 'cap:in="media:json";op=pretty;out="media:json;textable"',
|
|
1754
|
+
title: 'Pretty Print JSON',
|
|
1755
|
+
description: 'Format JSON',
|
|
1756
|
+
command: 'pretty'
|
|
1757
|
+
}
|
|
1758
|
+
]
|
|
1759
|
+
}
|
|
1760
|
+
],
|
|
1761
|
+
latestVersion: '0.1.0',
|
|
1762
|
+
versions: {
|
|
1763
|
+
'0.1.0': {
|
|
1764
|
+
releaseDate: '2026-04-01T00:00:00Z',
|
|
1765
|
+
changelog: ['Initial nightly'],
|
|
1766
|
+
minAppVersion: '1.0.0',
|
|
1767
|
+
builds: [{
|
|
1768
|
+
platform: 'darwin-arm64',
|
|
1769
|
+
package: {
|
|
1770
|
+
name: 'jsoncartridge-0.1.0.pkg',
|
|
1771
|
+
url: 'https://cartridges.machinefabric.com/nightly/jsoncartridge/0.1.0/jsoncartridge-0.1.0.pkg',
|
|
1772
|
+
sha256: 'deadbeef',
|
|
1773
|
+
size: 100000
|
|
1774
|
+
}
|
|
1775
|
+
}]
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
} }
|
|
1730
1780
|
}
|
|
1731
1781
|
};
|
|
1732
1782
|
|
|
@@ -1735,6 +1785,7 @@ function test320_cartridgeInfoConstruction() {
|
|
|
1735
1785
|
const data = {
|
|
1736
1786
|
id: 'testcartridge',
|
|
1737
1787
|
name: 'Test Cartridge',
|
|
1788
|
+
channel: 'release',
|
|
1738
1789
|
version: '1.0.0',
|
|
1739
1790
|
description: 'A test',
|
|
1740
1791
|
teamId: 'TEAM123',
|
|
@@ -1764,20 +1815,20 @@ function test320_cartridgeInfoConstruction() {
|
|
|
1764
1815
|
|
|
1765
1816
|
// TEST321: CartridgeInfo.is_signed() returns true when signature is present
|
|
1766
1817
|
function test321_cartridgeInfoIsSigned() {
|
|
1767
|
-
const signed = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', cap_groups: []});
|
|
1818
|
+
const signed = new CartridgeInfo({id: 'test', channel: 'release', teamId: 'TEAM', signedAt: '2026-01-01', cap_groups: []});
|
|
1768
1819
|
assert(signed.isSigned() === true, 'Cartridge with teamId and signedAt should be signed');
|
|
1769
1820
|
|
|
1770
|
-
const unsigned1 = new CartridgeInfo({id: 'test', teamId: '', signedAt: '2026-01-01', cap_groups: []});
|
|
1821
|
+
const unsigned1 = new CartridgeInfo({id: 'test', channel: 'release', teamId: '', signedAt: '2026-01-01', cap_groups: []});
|
|
1771
1822
|
assert(unsigned1.isSigned() === false, 'Cartridge without teamId should not be signed');
|
|
1772
1823
|
|
|
1773
|
-
const unsigned2 = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '', cap_groups: []});
|
|
1824
|
+
const unsigned2 = new CartridgeInfo({id: 'test', channel: 'release', teamId: 'TEAM', signedAt: '', cap_groups: []});
|
|
1774
1825
|
assert(unsigned2.isSigned() === false, 'Cartridge without signedAt should not be signed');
|
|
1775
1826
|
}
|
|
1776
1827
|
|
|
1777
1828
|
// TEST322: CartridgeInfo.build_for_platform() returns the build matching the current platform
|
|
1778
1829
|
function test322_cartridgeInfoBuildForPlatform() {
|
|
1779
1830
|
const withBuilds = new CartridgeInfo({
|
|
1780
|
-
id: 'test', version: '1.0.0', cap_groups: [],
|
|
1831
|
+
id: 'test', channel: 'release', version: '1.0.0', cap_groups: [],
|
|
1781
1832
|
versions: {
|
|
1782
1833
|
'1.0.0': {
|
|
1783
1834
|
builds: [
|
|
@@ -1803,51 +1854,64 @@ function test322_cartridgeInfoBuildForPlatform() {
|
|
|
1803
1854
|
assert(platforms.includes('darwin-arm64'), 'Should include darwin-arm64');
|
|
1804
1855
|
assert(platforms.includes('linux-x86_64'), 'Should include linux-x86_64');
|
|
1805
1856
|
|
|
1806
|
-
const noBuilds = new CartridgeInfo({id: 'test', version: '1.0.0', cap_groups: [], versions: {}, availableVersions: []});
|
|
1857
|
+
const noBuilds = new CartridgeInfo({id: 'test', channel: 'release', version: '1.0.0', cap_groups: [], versions: {}, availableVersions: []});
|
|
1807
1858
|
assert(noBuilds.buildForPlatform('darwin-arm64') === null, 'Should return null when no versions');
|
|
1808
1859
|
assert(noBuilds.availablePlatforms().length === 0, 'Should have no platforms');
|
|
1809
1860
|
}
|
|
1810
1861
|
|
|
1811
|
-
// TEST323: CartridgeRepoServer
|
|
1862
|
+
// TEST323: CartridgeRepoServer requires schema 5.0 and rejects older.
|
|
1812
1863
|
function test323_cartridgeRepoServerValidateRegistry() {
|
|
1813
1864
|
// Valid registry
|
|
1814
1865
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1815
|
-
assert(server.registry.schemaVersion === '
|
|
1866
|
+
assert(server.registry.schemaVersion === '5.0', 'Should accept v5.0 registry');
|
|
1816
1867
|
|
|
1817
|
-
// Invalid schema version
|
|
1868
|
+
// Invalid schema version (4.0 is no longer accepted)
|
|
1818
1869
|
let threw = false;
|
|
1819
1870
|
try {
|
|
1820
|
-
new CartridgeRepoServer({schemaVersion: '
|
|
1871
|
+
new CartridgeRepoServer({schemaVersion: '4.0', channels: {release: {cartridges: {}}, nightly: {cartridges: {}}}});
|
|
1821
1872
|
} catch (e) {
|
|
1822
1873
|
threw = true;
|
|
1823
|
-
assert(e.message.includes('
|
|
1874
|
+
assert(e.message.includes('5.0'), 'Should reject pre-5.0 schema and mention required version');
|
|
1824
1875
|
}
|
|
1825
|
-
assert(threw, 'Should throw for
|
|
1876
|
+
assert(threw, 'Should throw for v4.0 schema');
|
|
1826
1877
|
|
|
1827
|
-
// Missing
|
|
1878
|
+
// Missing channels object
|
|
1828
1879
|
threw = false;
|
|
1829
1880
|
try {
|
|
1830
|
-
new CartridgeRepoServer({schemaVersion: '
|
|
1881
|
+
new CartridgeRepoServer({schemaVersion: '5.0'});
|
|
1831
1882
|
} catch (e) {
|
|
1832
1883
|
threw = true;
|
|
1833
|
-
assert(e.message.includes('
|
|
1884
|
+
assert(e.message.includes('channels'), 'Should reject missing channels');
|
|
1834
1885
|
}
|
|
1835
|
-
assert(threw, 'Should throw for missing
|
|
1886
|
+
assert(threw, 'Should throw for missing channels');
|
|
1887
|
+
|
|
1888
|
+
// Missing one of the two required channels
|
|
1889
|
+
threw = false;
|
|
1890
|
+
try {
|
|
1891
|
+
new CartridgeRepoServer({schemaVersion: '5.0', channels: {release: {cartridges: {}}}});
|
|
1892
|
+
} catch (e) {
|
|
1893
|
+
threw = true;
|
|
1894
|
+
assert(e.message.includes('nightly'), 'Should require nightly channel');
|
|
1895
|
+
}
|
|
1896
|
+
assert(threw, 'Should throw when nightly channel is missing');
|
|
1836
1897
|
}
|
|
1837
1898
|
|
|
1838
|
-
// TEST324: CartridgeRepoServer
|
|
1899
|
+
// TEST324: CartridgeRepoServer walks both channels and emits a flat
|
|
1900
|
+
// CartridgeInfo array preserving channel provenance. Release entries
|
|
1901
|
+
// appear first.
|
|
1839
1902
|
function test324_cartridgeRepoServerTransformToArray() {
|
|
1840
1903
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1841
1904
|
const cartridges = server.transformToCartridgeArray();
|
|
1842
1905
|
|
|
1843
1906
|
assert(Array.isArray(cartridges), 'Should return array');
|
|
1844
|
-
assert(cartridges.length ===
|
|
1907
|
+
assert(cartridges.length === 3, 'Should have 3 cartridges across both channels');
|
|
1845
1908
|
|
|
1846
1909
|
const pdf = cartridges.find(p => p.id === 'pdfcartridge');
|
|
1847
1910
|
assert(pdf !== undefined, 'Should include pdfcartridge');
|
|
1848
1911
|
assert(pdf.version === '0.81.5325', 'Should have latest version');
|
|
1849
1912
|
assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
|
|
1850
1913
|
assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
|
|
1914
|
+
assert(pdf.channel === 'release', 'pdfcartridge should be in release channel');
|
|
1851
1915
|
assert(pdf.versions !== undefined, 'Should have versions');
|
|
1852
1916
|
assert(pdf.versions['0.81.5325'] !== undefined, 'Should have version data');
|
|
1853
1917
|
assert(pdf.versions['0.81.5325'].builds.length === 1, 'Should have 1 build');
|
|
@@ -1859,31 +1923,66 @@ function test324_cartridgeRepoServerTransformToArray() {
|
|
|
1859
1923
|
assert(Array.isArray(pdf.cap_groups), 'Should have cap_groups array');
|
|
1860
1924
|
assert(pdf.cap_groups.length === 1, 'Should have 1 cap_group');
|
|
1861
1925
|
assert(pdf.cap_groups[0].caps.length === 2, 'Should have 2 caps in the group');
|
|
1926
|
+
|
|
1927
|
+
const json = cartridges.find(p => p.id === 'jsoncartridge');
|
|
1928
|
+
assert(json !== undefined, 'Should include jsoncartridge from nightly channel');
|
|
1929
|
+
assert(json.channel === 'nightly', 'jsoncartridge should be in nightly channel');
|
|
1930
|
+
|
|
1931
|
+
// Release entries come before nightly so any UI that paints in
|
|
1932
|
+
// iteration order surfaces the user-facing channel first.
|
|
1933
|
+
const firstNightlyIdx = cartridges.findIndex(c => c.channel === 'nightly');
|
|
1934
|
+
const lastReleaseIdx = cartridges.map(c => c.channel).lastIndexOf('release');
|
|
1935
|
+
assert(firstNightlyIdx > lastReleaseIdx, 'Release entries must precede nightly entries');
|
|
1862
1936
|
}
|
|
1863
1937
|
|
|
1864
|
-
// TEST325: CartridgeRepoServer.
|
|
1938
|
+
// TEST325: CartridgeRepoServer.getCartridges() wraps the transformed
|
|
1939
|
+
// flat array (across both channels) in the response envelope.
|
|
1865
1940
|
function test325_cartridgeRepoServerGetCartridges() {
|
|
1866
1941
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1867
1942
|
const response = server.getCartridges();
|
|
1868
1943
|
|
|
1869
1944
|
assert(response.cartridges !== undefined, 'Should have cartridges field');
|
|
1870
1945
|
assert(Array.isArray(response.cartridges), 'Cartridges should be array');
|
|
1871
|
-
assert(response.cartridges.length ===
|
|
1946
|
+
assert(response.cartridges.length === 3, 'Should have 3 cartridges total');
|
|
1947
|
+
assert(response.cartridges.every(c => c.channel === 'release' || c.channel === 'nightly'),
|
|
1948
|
+
'Every cartridge must carry a channel');
|
|
1872
1949
|
}
|
|
1873
1950
|
|
|
1874
|
-
// TEST326: CartridgeRepoServer.
|
|
1951
|
+
// TEST326: CartridgeRepoServer.getCartridgeById() requires (channel,
|
|
1952
|
+
// id). Same id looked up in the wrong channel must miss — channels are
|
|
1953
|
+
// independent namespaces.
|
|
1875
1954
|
function test326_cartridgeRepoServerGetCartridgeById() {
|
|
1876
1955
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1877
1956
|
|
|
1878
|
-
const pdf = server.getCartridgeById('pdfcartridge');
|
|
1879
|
-
assert(pdf !== undefined, 'Should find pdfcartridge');
|
|
1957
|
+
const pdf = server.getCartridgeById('release', 'pdfcartridge');
|
|
1958
|
+
assert(pdf !== undefined, 'Should find pdfcartridge in release');
|
|
1880
1959
|
assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
|
|
1960
|
+
assert(pdf.channel === 'release', 'Should report release channel');
|
|
1961
|
+
|
|
1962
|
+
const json = server.getCartridgeById('nightly', 'jsoncartridge');
|
|
1963
|
+
assert(json !== undefined, 'Should find jsoncartridge in nightly');
|
|
1964
|
+
assert(json.channel === 'nightly', 'Should report nightly channel');
|
|
1881
1965
|
|
|
1882
|
-
const
|
|
1966
|
+
const wrongChannel = server.getCartridgeById('nightly', 'pdfcartridge');
|
|
1967
|
+
assert(wrongChannel === undefined, 'pdf not in nightly — channels are independent');
|
|
1968
|
+
|
|
1969
|
+
const notFound = server.getCartridgeById('release', 'nonexistent');
|
|
1883
1970
|
assert(notFound === undefined, 'Should return undefined for missing cartridge');
|
|
1971
|
+
|
|
1972
|
+
let threw = false;
|
|
1973
|
+
try {
|
|
1974
|
+
server.getCartridgeById('staging', 'pdfcartridge');
|
|
1975
|
+
} catch (e) {
|
|
1976
|
+
threw = true;
|
|
1977
|
+
assert(e.message.includes('release') && e.message.includes('nightly'),
|
|
1978
|
+
'Should reject invalid channel value');
|
|
1979
|
+
}
|
|
1980
|
+
assert(threw, 'Should throw for invalid channel');
|
|
1884
1981
|
}
|
|
1885
1982
|
|
|
1886
|
-
// TEST327: CartridgeRepoServer.
|
|
1983
|
+
// TEST327: CartridgeRepoServer.searchCartridges() filters across both
|
|
1984
|
+
// channels by name/description/tags/cap titles. Cap URN strings are
|
|
1985
|
+
// not substring-matched.
|
|
1887
1986
|
function test327_cartridgeRepoServerSearchCartridges() {
|
|
1888
1987
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1889
1988
|
|
|
@@ -1894,11 +1993,18 @@ function test327_cartridgeRepoServerSearchCartridges() {
|
|
|
1894
1993
|
const metadataResults = server.searchCartridges('metadata');
|
|
1895
1994
|
assert(metadataResults.length === 1, 'Should find cartridge by cap title');
|
|
1896
1995
|
|
|
1996
|
+
// Search must reach into the nightly channel too — channels are not
|
|
1997
|
+
// a search-time filter.
|
|
1998
|
+
const jsonResults = server.searchCartridges('json');
|
|
1999
|
+
assert(jsonResults.length === 1, 'Should reach nightly cartridges');
|
|
2000
|
+
assert(jsonResults[0].channel === 'nightly', 'Should report nightly channel');
|
|
2001
|
+
|
|
1897
2002
|
const noResults = server.searchCartridges('nonexistent');
|
|
1898
2003
|
assert(noResults.length === 0, 'Should return empty for no matches');
|
|
1899
2004
|
}
|
|
1900
2005
|
|
|
1901
|
-
// TEST328: CartridgeRepoServer.
|
|
2006
|
+
// TEST328: CartridgeRepoServer.getCartridgesByCategory() filters
|
|
2007
|
+
// cartridges by category across both channels.
|
|
1902
2008
|
function test328_cartridgeRepoServerGetByCategory() {
|
|
1903
2009
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1904
2010
|
|
|
@@ -1909,9 +2015,15 @@ function test328_cartridgeRepoServerGetByCategory() {
|
|
|
1909
2015
|
const textCartridges = server.getCartridgesByCategory('text');
|
|
1910
2016
|
assert(textCartridges.length === 1, 'Should find 1 text cartridge');
|
|
1911
2017
|
assert(textCartridges[0].id === 'txtcartridge', 'Should be txtcartridge');
|
|
2018
|
+
|
|
2019
|
+
const dataCartridges = server.getCartridgesByCategory('data');
|
|
2020
|
+
assert(dataCartridges.length === 1, 'Category lookup should reach the nightly channel too');
|
|
2021
|
+
assert(dataCartridges[0].channel === 'nightly', 'Should be the nightly entry');
|
|
1912
2022
|
}
|
|
1913
2023
|
|
|
1914
|
-
// TEST329: CartridgeRepoServer.
|
|
2024
|
+
// TEST329: CartridgeRepoServer.getCartridgesByCap() parses the input
|
|
2025
|
+
// URN and matches each declared cap via `conformsTo`. Tag-order
|
|
2026
|
+
// differences resolve because matching is order-theoretic, not string.
|
|
1915
2027
|
function test329_cartridgeRepoServerGetByCap() {
|
|
1916
2028
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
1917
2029
|
|
|
@@ -1926,7 +2038,9 @@ function test329_cartridgeRepoServerGetByCap() {
|
|
|
1926
2038
|
assert(metadataCartridges.length === 1, 'Should find metadata cap');
|
|
1927
2039
|
}
|
|
1928
2040
|
|
|
1929
|
-
// TEST330: CartridgeRepoClient updates its local cache
|
|
2041
|
+
// TEST330: CartridgeRepoClient updates its local cache keyed by
|
|
2042
|
+
// "<channel>:<id>". The cache holds release and nightly entries
|
|
2043
|
+
// independently — the same id is allowed in both.
|
|
1930
2044
|
function test330_cartridgeRepoClientUpdateCache() {
|
|
1931
2045
|
const client = new CartridgeRepoClient(3600);
|
|
1932
2046
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -1936,11 +2050,14 @@ function test330_cartridgeRepoClientUpdateCache() {
|
|
|
1936
2050
|
|
|
1937
2051
|
const cache = client.caches.get('https://example.com/api/cartridges');
|
|
1938
2052
|
assert(cache !== undefined, 'Cache should exist');
|
|
1939
|
-
assert(cache.cartridges.size ===
|
|
2053
|
+
assert(cache.cartridges.size === 3, 'Should have 3 cartridges in cache (2 release + 1 nightly)');
|
|
2054
|
+
assert(cache.cartridges.has('release:pdfcartridge'), 'Should key by <channel>:<id>');
|
|
2055
|
+
assert(cache.cartridges.has('nightly:jsoncartridge'), 'Should hold nightly entry independently');
|
|
1940
2056
|
assert(cache.capToCartridges.size > 0, 'Should have cap mappings');
|
|
1941
2057
|
}
|
|
1942
2058
|
|
|
1943
|
-
// TEST331: CartridgeRepoClient.
|
|
2059
|
+
// TEST331: CartridgeRepoClient.getSuggestionsForCap() returns cartridge
|
|
2060
|
+
// suggestions with channel propagated onto each suggestion.
|
|
1944
2061
|
function test331_cartridgeRepoClientGetSuggestions() {
|
|
1945
2062
|
const client = new CartridgeRepoClient(3600);
|
|
1946
2063
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -1953,11 +2070,24 @@ function test331_cartridgeRepoClientGetSuggestions() {
|
|
|
1953
2070
|
|
|
1954
2071
|
assert(suggestions.length === 1, 'Should find 1 suggestion');
|
|
1955
2072
|
assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest pdfcartridge');
|
|
1956
|
-
assert(suggestions[0].
|
|
2073
|
+
assert(suggestions[0].channel === 'release', 'Channel must propagate from cache');
|
|
2074
|
+
// The returned capUrn is the canonical (normalized) form. Compare via
|
|
2075
|
+
// tagged-URN equivalence rather than string equality so a tag-order
|
|
2076
|
+
// difference between the request and the canonical form is tolerated.
|
|
2077
|
+
const requested = CapUrn.fromString(disbindCap);
|
|
2078
|
+
const returned = CapUrn.fromString(suggestions[0].capUrn);
|
|
2079
|
+
assert(returned.isEquivalent(requested), 'Should have equivalent cap URN');
|
|
1957
2080
|
assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
|
|
2081
|
+
|
|
2082
|
+
// Nightly cap should also surface a suggestion, with channel set to nightly.
|
|
2083
|
+
const prettyCap = 'cap:in="media:json";op=pretty;out="media:json;textable"';
|
|
2084
|
+
const nightlySuggestions = client.getSuggestionsForCap(prettyCap);
|
|
2085
|
+
assert(nightlySuggestions.length === 1, 'Should find nightly cap suggestion');
|
|
2086
|
+
assert(nightlySuggestions[0].channel === 'nightly', 'Should report nightly channel');
|
|
1958
2087
|
}
|
|
1959
2088
|
|
|
1960
|
-
// TEST332: CartridgeRepoClient.
|
|
2089
|
+
// TEST332: CartridgeRepoClient.getCartridge() requires (channel, id).
|
|
2090
|
+
// Same id in the wrong channel must miss.
|
|
1961
2091
|
function test332_cartridgeRepoClientGetCartridge() {
|
|
1962
2092
|
const client = new CartridgeRepoClient(3600);
|
|
1963
2093
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -1965,15 +2095,33 @@ function test332_cartridgeRepoClientGetCartridge() {
|
|
|
1965
2095
|
|
|
1966
2096
|
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
1967
2097
|
|
|
1968
|
-
const cartridge = client.getCartridge('pdfcartridge');
|
|
1969
|
-
assert(cartridge !== null, 'Should find cartridge');
|
|
2098
|
+
const cartridge = client.getCartridge('release', 'pdfcartridge');
|
|
2099
|
+
assert(cartridge !== null && cartridge !== undefined, 'Should find cartridge in release');
|
|
1970
2100
|
assert(cartridge.id === 'pdfcartridge', 'Should have correct ID');
|
|
2101
|
+
assert(cartridge.channel === 'release', 'Should report release channel');
|
|
2102
|
+
|
|
2103
|
+
const json = client.getCartridge('nightly', 'jsoncartridge');
|
|
2104
|
+
assert(json !== null && json !== undefined, 'Should find nightly entry');
|
|
2105
|
+
assert(json.channel === 'nightly', 'Should report nightly channel');
|
|
1971
2106
|
|
|
1972
|
-
const
|
|
1973
|
-
assert(
|
|
2107
|
+
const wrongChannel = client.getCartridge('nightly', 'pdfcartridge');
|
|
2108
|
+
assert(wrongChannel === undefined || wrongChannel === null,
|
|
2109
|
+
'Should miss when looking up release id in nightly channel');
|
|
2110
|
+
|
|
2111
|
+
const notFound = client.getCartridge('release', 'nonexistent');
|
|
2112
|
+
assert(notFound === undefined || notFound === null, 'Should miss for unknown id');
|
|
2113
|
+
|
|
2114
|
+
let threw = false;
|
|
2115
|
+
try {
|
|
2116
|
+
client.getCartridge('staging', 'pdfcartridge');
|
|
2117
|
+
} catch (e) {
|
|
2118
|
+
threw = true;
|
|
2119
|
+
}
|
|
2120
|
+
assert(threw, 'Should throw for invalid channel');
|
|
1974
2121
|
}
|
|
1975
2122
|
|
|
1976
|
-
// TEST333: CartridgeRepoClient.
|
|
2123
|
+
// TEST333: CartridgeRepoClient.getAllAvailableCaps() returns the set
|
|
2124
|
+
// of normalized URNs across both channels.
|
|
1977
2125
|
function test333_cartridgeRepoClientGetAllCaps() {
|
|
1978
2126
|
const client = new CartridgeRepoClient(3600);
|
|
1979
2127
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -1983,11 +2131,14 @@ function test333_cartridgeRepoClientGetAllCaps() {
|
|
|
1983
2131
|
|
|
1984
2132
|
const caps = client.getAllAvailableCaps();
|
|
1985
2133
|
assert(Array.isArray(caps), 'Should return array');
|
|
1986
|
-
|
|
2134
|
+
// 2 caps from pdfcartridge + 1 from txtcartridge + 1 from
|
|
2135
|
+
// jsoncartridge (nightly) = 4 unique caps.
|
|
2136
|
+
assert(caps.length === 4, `Should have 4 unique caps, got ${caps.length}`);
|
|
1987
2137
|
assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
|
|
1988
2138
|
}
|
|
1989
2139
|
|
|
1990
|
-
// TEST334: CartridgeRepoClient.
|
|
2140
|
+
// TEST334: CartridgeRepoClient.needsSync() returns true when cache is
|
|
2141
|
+
// empty / stale, false right after a fresh update.
|
|
1991
2142
|
function test334_cartridgeRepoClientNeedsSync() {
|
|
1992
2143
|
const client = new CartridgeRepoClient(1); // 1 second TTL
|
|
1993
2144
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -2003,12 +2154,10 @@ function test334_cartridgeRepoClientNeedsSync() {
|
|
|
2003
2154
|
|
|
2004
2155
|
// Should not need sync immediately
|
|
2005
2156
|
assert(client.needsSync(urls) === false, 'Should not need sync right after update');
|
|
2006
|
-
|
|
2007
|
-
// Wait for cache to expire (1 second)
|
|
2008
|
-
// Note: Can't test this synchronously, would need async test
|
|
2009
2157
|
}
|
|
2010
2158
|
|
|
2011
|
-
// TEST335:
|
|
2159
|
+
// TEST335: Round-trip: server produces a v5.0 response, client consumes
|
|
2160
|
+
// it, channel provenance is preserved end-to-end.
|
|
2012
2161
|
function test335_cartridgeRepoServerClientIntegration() {
|
|
2013
2162
|
// Server creates API response
|
|
2014
2163
|
const server = new CartridgeRepoServer(sampleRegistry);
|
|
@@ -2019,10 +2168,11 @@ function test335_cartridgeRepoServerClientIntegration() {
|
|
|
2019
2168
|
const cartridges = apiResponse.cartridges.map(p => new CartridgeInfo(p));
|
|
2020
2169
|
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2021
2170
|
|
|
2022
|
-
// Client can find cartridge
|
|
2023
|
-
const cartridge = client.getCartridge('pdfcartridge');
|
|
2024
|
-
assert(cartridge !== null, 'Client should find cartridge from server data');
|
|
2171
|
+
// Client can find cartridge by (channel, id)
|
|
2172
|
+
const cartridge = client.getCartridge('release', 'pdfcartridge');
|
|
2173
|
+
assert(cartridge !== null && cartridge !== undefined, 'Client should find cartridge from server data');
|
|
2025
2174
|
assert(cartridge.isSigned(), 'Cartridge should be signed');
|
|
2175
|
+
assert(cartridge.channel === 'release', 'Should report release channel');
|
|
2026
2176
|
assert(cartridge.buildForPlatform('darwin-arm64') !== null, 'Cartridge should have darwin-arm64 build');
|
|
2027
2177
|
|
|
2028
2178
|
// Client can get suggestions
|
|
@@ -2030,6 +2180,7 @@ function test335_cartridgeRepoServerClientIntegration() {
|
|
|
2030
2180
|
const suggestions = client.getSuggestionsForCap(capUrn);
|
|
2031
2181
|
assert(suggestions.length === 1, 'Should get suggestions');
|
|
2032
2182
|
assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest correct cartridge');
|
|
2183
|
+
assert(suggestions[0].channel === 'release', 'Suggestion should preserve channel');
|
|
2033
2184
|
|
|
2034
2185
|
// Server can search
|
|
2035
2186
|
const searchResults = server.searchCartridges('pdf');
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"author": "Bahram Joharshamshiri",
|
|
3
3
|
"dependencies": {
|
|
4
4
|
"peggy": "^5.1.0",
|
|
5
|
-
"tagged-urn": "^0.
|
|
5
|
+
"tagged-urn": "^0.34.83"
|
|
6
6
|
},
|
|
7
7
|
"description": "JavaScript implementation of Cap URN (Capability Uniform Resource Names) with strict validation and matching",
|
|
8
8
|
"engines": {
|
|
@@ -40,5 +40,5 @@
|
|
|
40
40
|
"pretest": "npm run build:parser",
|
|
41
41
|
"test": "node capdag.test.js"
|
|
42
42
|
},
|
|
43
|
-
"version": "0.
|
|
43
|
+
"version": "0.152.345"
|
|
44
44
|
}
|