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.
Files changed (3) hide show
  1. package/capdag.js +234 -104
  2. package/capdag.test.js +199 -48
  3. 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(); // cartridge_id -> CartridgeInfo
3706
- this.capToCartridges = new Map(); // cap_urn -> [cartridge_ids]
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
- if (!data.cartridges || typeof data.cartridges !== 'object') {
3734
- throw new Error(`Invalid cartridge registry response from ${repoUrl}: missing cartridges object`);
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
- // Registry stores cartridges as an object keyed by id; normalize to array.
3738
- return Object.entries(data.cartridges).map(([id, c]) => new CartridgeInfo({
3739
- ...c,
3740
- id,
3741
- version: c.latestVersion
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
- if (!cache.capToCartridges.has(cap.urn)) {
3756
- cache.capToCartridges.set(cap.urn, []);
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(cap.urn).push(cartridge.id);
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 cartridgeIds = cache.capToCartridges.get(capUrn);
3808
- if (!cartridgeIds) continue;
3865
+ const refs = cache.capToCartridges.get(normalized);
3866
+ if (!refs) continue;
3809
3867
 
3810
- for (const cartridgeId of cartridgeIds) {
3811
- const cartridge = cache.cartridges.get(cartridgeId);
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 => c.urn === capUrn);
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: 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 [cartridgeId, cartridgeInfo] of cache.cartridges) {
3842
- cartridges.push([cartridgeId, cartridgeInfo]);
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 ID
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(cartridgeId);
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 !== '4.0') {
3908
- throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 4.0`);
4001
+ if (this.registry.schemaVersion !== '5.0') {
4002
+ throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 5.0`);
3909
4003
  }
3910
- if (!this.registry.cartridges || typeof this.registry.cartridges !== 'object') {
3911
- throw new Error('Registry must have cartridges object');
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
- * Transform registry to flat cartridge array
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
- transformToCartridgeArray() {
3957
- const cartridgesObject = this.registry.cartridges || {};
3958
- const cartridges = [];
3959
-
3960
- for (const [id, cartridge] of Object.entries(cartridgesObject)) {
3961
- const latestVersion = cartridge.latestVersion;
3962
- const versionData = cartridge.versions[latestVersion];
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
- if (!versionData) {
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
- // Validate required fields - fail hard
3969
- this.validateVersionData(id, latestVersion, versionData);
4066
+ if (!Array.isArray(cartridge.cap_groups)) {
4067
+ throw new Error(`Cartridge ${id} (${channel}): missing cap_groups array`);
4068
+ }
3970
4069
 
3971
- // Get all version numbers sorted descending
3972
- const availableVersions = Object.keys(cartridge.versions).sort((a, b) => {
3973
- return this.compareVersions(b, a);
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
- cartridges.push({
3977
- id,
3978
- name: cartridge.name,
3979
- version: latestVersion,
3980
- description: cartridge.description,
3981
- author: cartridge.author,
3982
- pageUrl: cartridge.pageUrl || '',
3983
- teamId: cartridge.teamId,
3984
- signedAt: versionData.releaseDate,
3985
- minAppVersion: versionData.minAppVersion || cartridge.minAppVersion,
3986
- cap_groups: (() => {
3987
- if (!Array.isArray(cartridge.cap_groups)) throw new Error(`Cartridge ${id}: missing cap_groups array`);
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 ID
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
- const cartridges = this.transformToCartridgeArray();
4014
- return cartridges.find(p => p.id === id);
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.urn.toLowerCase().includes(lowerQuery) || c.title.toLowerCase().includes(lowerQuery));
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 => (g.caps || []).some(c => c.urn === capUrn))
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: '4.0',
1647
+ schemaVersion: '5.0',
1643
1648
  lastUpdated: '2026-02-07T16:48:28Z',
1644
- cartridges: {
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 validates registry JSON schema version
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 === '4.0', 'Should accept valid registry');
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: '3.0', cartridges: {}});
1871
+ new CartridgeRepoServer({schemaVersion: '4.0', channels: {release: {cartridges: {}}, nightly: {cartridges: {}}}});
1821
1872
  } catch (e) {
1822
1873
  threw = true;
1823
- assert(e.message.includes('schema version'), 'Should reject wrong schema version');
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 invalid schema');
1876
+ assert(threw, 'Should throw for v4.0 schema');
1826
1877
 
1827
- // Missing cartridges
1878
+ // Missing channels object
1828
1879
  threw = false;
1829
1880
  try {
1830
- new CartridgeRepoServer({schemaVersion: '4.0'});
1881
+ new CartridgeRepoServer({schemaVersion: '5.0'});
1831
1882
  } catch (e) {
1832
1883
  threw = true;
1833
- assert(e.message.includes('cartridges'), 'Should reject missing cartridges');
1884
+ assert(e.message.includes('channels'), 'Should reject missing channels');
1834
1885
  }
1835
- assert(threw, 'Should throw for missing cartridges');
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 transforms v3 registry JSON into flat cartridge array
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 === 2, 'Should have 2 cartridges');
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.get_cartridges() returns all parsed cartridges
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 === 2, 'Should have 2 cartridges');
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.get_cartridge() returns cartridge matching the given ID
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 notFound = server.getCartridgeById('nonexistent');
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.search_cartridges() filters by text query against name and description
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.get_by_category() filters cartridges by category tag
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.get_suggestions_for_cap() finds cartridges providing a given cap URN
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 from server response
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 === 2, 'Should have 2 cartridges in cache');
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.get_suggestions_for_cap() returns cartridge suggestions for a cap URN
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].capUrn === disbindCap, 'Should have correct cap URN');
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.get_cartridge() retrieves a specific cartridge by ID from cache
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 notFound = client.getCartridge('nonexistent');
1973
- assert(notFound === null, 'Should return null for missing cartridge');
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.get_all_caps() returns aggregate cap URNs from all cached cartridges
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
- assert(caps.length === 3, 'Should have 3 unique caps');
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.needs_sync() returns true when cache TTL has expired
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: Server creates registry response and client consumes it end-to-end
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.33.81"
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.148.329"
43
+ "version": "0.152.345"
44
44
  }