capdag 0.149.334 → 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 +198 -115
  2. package/capdag.test.js +193 -47
  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,52 +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
3782
  * Update cache from registry data.
3747
3783
  *
3748
- * The cap-to-cartridges index keys on the *normalized* tagged-URN form
3749
- * of each cap URN (parse via CapUrn.fromString, then take toString()).
3750
- * This collapses textually different but canonically identical URNs
3751
- * (different declared tag order) into the same bucket so that lookups
3752
- * resolve regardless of how the requester phrased the URN. A cap URN
3753
- * that fails to parse is a registry corruption: we throw rather than
3754
- * silently keep the malformed string in the index.
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.
3755
3792
  */
3756
3793
  updateCache(repoUrl, cartridges) {
3757
3794
  const cache = new CartridgeRepoCache(repoUrl);
3758
3795
 
3759
3796
  for (const cartridge of cartridges) {
3760
- cache.cartridges.set(cartridge.id, cartridge);
3797
+ cache.cartridges.set(_cacheKey(cartridge.channel, cartridge.id), cartridge);
3761
3798
 
3762
3799
  for (const cap of cartridge.allCaps()) {
3763
3800
  const normalized = CapUrn.fromString(cap.urn).toString();
3764
3801
  if (!cache.capToCartridges.has(normalized)) {
3765
3802
  cache.capToCartridges.set(normalized, []);
3766
3803
  }
3767
- cache.capToCartridges.get(normalized).push(cartridge.id);
3804
+ cache.capToCartridges.get(normalized).push({
3805
+ channel: cartridge.channel,
3806
+ id: cartridge.id
3807
+ });
3768
3808
  }
3769
3809
  }
3770
3810
 
@@ -3812,8 +3852,9 @@ class CartridgeRepoClient {
3812
3852
  * `capUrn` is parsed via CapUrn.fromString; the parsed-and-
3813
3853
  * re-serialized form is the canonical key into the cap-to-cartridges
3814
3854
  * index. Inside each candidate cartridge we walk its caps via
3815
- * `allCaps()` and match each one with `isEquivalent`, never with
3816
- * string equality.
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.
3817
3858
  */
3818
3859
  getSuggestionsForCap(capUrn) {
3819
3860
  const requested = CapUrn.fromString(capUrn);
@@ -3821,11 +3862,11 @@ class CartridgeRepoClient {
3821
3862
  const suggestions = [];
3822
3863
 
3823
3864
  for (const cache of this.caches.values()) {
3824
- const cartridgeIds = cache.capToCartridges.get(normalized);
3825
- if (!cartridgeIds) continue;
3865
+ const refs = cache.capToCartridges.get(normalized);
3866
+ if (!refs) continue;
3826
3867
 
3827
- for (const cartridgeId of cartridgeIds) {
3828
- const cartridge = cache.cartridges.get(cartridgeId);
3868
+ for (const ref of refs) {
3869
+ const cartridge = cache.cartridges.get(_cacheKey(ref.channel, ref.id));
3829
3870
  if (!cartridge) continue;
3830
3871
 
3831
3872
  const capInfo = cartridge.allCaps().find(c => {
@@ -3849,7 +3890,8 @@ class CartridgeRepoClient {
3849
3890
  capTitle: capInfo.title,
3850
3891
  latestVersion: cartridge.version,
3851
3892
  repoUrl: cache.repoUrl,
3852
- pageUrl: pageUrl
3893
+ pageUrl: pageUrl,
3894
+ channel: cartridge.channel
3853
3895
  }));
3854
3896
  }
3855
3897
  }
@@ -3858,13 +3900,15 @@ class CartridgeRepoClient {
3858
3900
  }
3859
3901
 
3860
3902
  /**
3861
- * 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.
3862
3906
  */
3863
3907
  getAllCartridges() {
3864
3908
  const cartridges = [];
3865
3909
  for (const cache of this.caches.values()) {
3866
- for (const [cartridgeId, cartridgeInfo] of cache.cartridges) {
3867
- cartridges.push([cartridgeId, cartridgeInfo]);
3910
+ for (const cartridgeInfo of cache.cartridges.values()) {
3911
+ cartridges.push([cartridgeInfo.channel, cartridgeInfo.id, cartridgeInfo]);
3868
3912
  }
3869
3913
  }
3870
3914
  return cartridges;
@@ -3884,11 +3928,17 @@ class CartridgeRepoClient {
3884
3928
  }
3885
3929
 
3886
3930
  /**
3887
- * 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.
3888
3934
  */
3889
- 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);
3890
3940
  for (const cache of this.caches.values()) {
3891
- const cartridge = cache.cartridges.get(cartridgeId);
3941
+ const cartridge = cache.cartridges.get(key);
3892
3942
  if (cartridge) {
3893
3943
  return cartridge;
3894
3944
  }
@@ -3916,51 +3966,74 @@ class CartridgeRepoClient {
3916
3966
  /**
3917
3967
  * Cartridge repository server - serves registry data with queries
3918
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
+ */
3919
3991
  class CartridgeRepoServer {
3920
3992
  constructor(registry) {
3921
3993
  this.registry = registry;
3922
3994
  this.validateRegistry();
3923
3995
  }
3924
3996
 
3925
- /**
3926
- * Validate registry schema
3927
- */
3928
3997
  validateRegistry() {
3929
3998
  if (!this.registry) {
3930
3999
  throw new Error('Registry is required');
3931
4000
  }
3932
- if (this.registry.schemaVersion !== '4.0') {
3933
- 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`);
4003
+ }
4004
+ const channels = this.registry.channels;
4005
+ if (!channels || typeof channels !== 'object') {
4006
+ throw new Error('Registry must have a channels object');
3934
4007
  }
3935
- if (!this.registry.cartridges || typeof this.registry.cartridges !== 'object') {
3936
- throw new Error('Registry must have cartridges object');
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
+ }
3937
4016
  }
3938
4017
  }
3939
4018
 
3940
- /**
3941
- * Validate version data has all required fields
3942
- */
3943
- validateVersionData(id, version, versionData) {
4019
+ validateVersionData(channel, id, version, versionData) {
3944
4020
  if (!Array.isArray(versionData.builds) || versionData.builds.length === 0) {
3945
- throw new Error(`Cartridge ${id} v${version}: no builds`);
4021
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: no builds`);
3946
4022
  }
3947
4023
  for (let i = 0; i < versionData.builds.length; i++) {
3948
4024
  const build = versionData.builds[i];
3949
4025
  if (!build.platform) {
3950
- throw new Error(`Cartridge ${id} v${version}: build[${i}] missing platform`);
4026
+ throw new Error(`Cartridge ${id} (${channel}) v${version}: build[${i}] missing platform`);
3951
4027
  }
3952
4028
  if (!build.package || !build.package.name) {
3953
- 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`);
3954
4030
  }
3955
4031
  if (!build.package.url) {
3956
- 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`);
3957
4033
  }
3958
4034
  }
3959
4035
  }
3960
4036
 
3961
- /**
3962
- * Compare version strings
3963
- */
3964
4037
  compareVersions(a, b) {
3965
4038
  const partsA = a.split('.').map(x => parseInt(x) || 0);
3966
4039
  const partsB = b.split('.').map(x => parseInt(x) || 0);
@@ -3976,71 +4049,82 @@ class CartridgeRepoServer {
3976
4049
  }
3977
4050
 
3978
4051
  /**
3979
- * 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.
3980
4055
  */
3981
- transformToCartridgeArray() {
3982
- const cartridgesObject = this.registry.cartridges || {};
3983
- const cartridges = [];
3984
-
3985
- for (const [id, cartridge] of Object.entries(cartridgesObject)) {
3986
- const latestVersion = cartridge.latestVersion;
3987
- 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);
3988
4063
 
3989
- if (!versionData) {
3990
- throw new Error(`Cartridge ${id}: latest version ${latestVersion} not found in versions`);
3991
- }
4064
+ const availableVersions = Object.keys(cartridge.versions).sort((a, b) => this.compareVersions(b, a));
3992
4065
 
3993
- // Validate required fields - fail hard
3994
- this.validateVersionData(id, latestVersion, versionData);
4066
+ if (!Array.isArray(cartridge.cap_groups)) {
4067
+ throw new Error(`Cartridge ${id} (${channel}): missing cap_groups array`);
4068
+ }
3995
4069
 
3996
- // Get all version numbers sorted descending
3997
- const availableVersions = Object.keys(cartridge.versions).sort((a, b) => {
3998
- return this.compareVersions(b, a);
3999
- });
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
+ }
4000
4088
 
4001
- cartridges.push({
4002
- id,
4003
- name: cartridge.name,
4004
- version: latestVersion,
4005
- description: cartridge.description,
4006
- author: cartridge.author,
4007
- pageUrl: cartridge.pageUrl || '',
4008
- teamId: cartridge.teamId,
4009
- signedAt: versionData.releaseDate,
4010
- minAppVersion: versionData.minAppVersion || cartridge.minAppVersion,
4011
- cap_groups: (() => {
4012
- if (!Array.isArray(cartridge.cap_groups)) throw new Error(`Cartridge ${id}: missing cap_groups array`);
4013
- return cartridge.cap_groups;
4014
- })(),
4015
- categories: cartridge.categories,
4016
- tags: cartridge.tags,
4017
- versions: cartridge.versions,
4018
- availableVersions
4019
- });
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
+ }
4020
4101
  }
4021
-
4022
- return cartridges;
4102
+ return out;
4023
4103
  }
4024
4104
 
4025
4105
  /**
4026
- * Get all cartridges (API response format)
4106
+ * Get all cartridges (API response format) — both channels.
4027
4107
  */
4028
4108
  getCartridges() {
4029
- return {
4030
- cartridges: this.transformToCartridgeArray()
4031
- };
4109
+ return { cartridges: this.transformToCartridgeArray() };
4032
4110
  }
4033
4111
 
4034
4112
  /**
4035
- * 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.
4036
4116
  */
4037
- getCartridgeById(id) {
4038
- const cartridges = this.transformToCartridgeArray();
4039
- 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);
4040
4124
  }
4041
4125
 
4042
4126
  /**
4043
- * Search cartridges by free-text query.
4127
+ * Search cartridges by free-text query across both channels.
4044
4128
  *
4045
4129
  * Matches against cartridge name, description, tags, and cap titles.
4046
4130
  * Cap URN strings are not substring-matched: a cap URN is a tagged
@@ -4051,7 +4135,6 @@ class CartridgeRepoServer {
4051
4135
  searchCartridges(query) {
4052
4136
  const cartridges = this.transformToCartridgeArray();
4053
4137
  const lowerQuery = query.toLowerCase();
4054
-
4055
4138
  return cartridges.filter(p => {
4056
4139
  const allCaps = (p.cap_groups || []).flatMap(g => g.caps || []);
4057
4140
  return p.name.toLowerCase().includes(lowerQuery) ||
@@ -4062,7 +4145,7 @@ class CartridgeRepoServer {
4062
4145
  }
4063
4146
 
4064
4147
  /**
4065
- * Get cartridges by category
4148
+ * Get cartridges by category — both channels.
4066
4149
  */
4067
4150
  getCartridgesByCategory(category) {
4068
4151
  const cartridges = this.transformToCartridgeArray();
@@ -4072,10 +4155,14 @@ class CartridgeRepoServer {
4072
4155
  /**
4073
4156
  * Get cartridges that provide a specific cap.
4074
4157
  *
4075
- * Both the request URN and each candidate cap URN are parsed via
4076
- * CapUrn.fromString and matched with `isEquivalent` so caps declared
4077
- * in any tag order resolve. A malformed input URN throws — there is
4078
- * no fallback that compares the raw strings.
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).
4079
4166
  */
4080
4167
  getCartridgesByCap(capUrn) {
4081
4168
  const requested = CapUrn.fromString(capUrn);
@@ -4083,13 +4170,8 @@ class CartridgeRepoServer {
4083
4170
  return cartridges.filter(p =>
4084
4171
  (p.cap_groups || []).some(g =>
4085
4172
  (g.caps || []).some(c => {
4086
- let parsed;
4087
- try {
4088
- parsed = CapUrn.fromString(c.urn);
4089
- } catch (_e) {
4090
- return false;
4091
- }
4092
- return parsed.isEquivalent(requested);
4173
+ const declared = CapUrn.fromString(c.urn);
4174
+ return declared.conformsTo(requested);
4093
4175
  })
4094
4176
  )
4095
4177
  );
@@ -5326,6 +5408,7 @@ module.exports = {
5326
5408
  CartridgeRepoCache,
5327
5409
  CartridgeRepoClient,
5328
5410
  CartridgeRepoServer,
5411
+ CartridgeChannel,
5329
5412
  // Machine notation
5330
5413
  MachineSyntaxError,
5331
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,6 +2070,7 @@ 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');
2073
+ assert(suggestions[0].channel === 'release', 'Channel must propagate from cache');
1956
2074
  // The returned capUrn is the canonical (normalized) form. Compare via
1957
2075
  // tagged-URN equivalence rather than string equality so a tag-order
1958
2076
  // difference between the request and the canonical form is tolerated.
@@ -1960,9 +2078,16 @@ function test331_cartridgeRepoClientGetSuggestions() {
1960
2078
  const returned = CapUrn.fromString(suggestions[0].capUrn);
1961
2079
  assert(returned.isEquivalent(requested), 'Should have equivalent cap URN');
1962
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');
1963
2087
  }
1964
2088
 
1965
- // 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.
1966
2091
  function test332_cartridgeRepoClientGetCartridge() {
1967
2092
  const client = new CartridgeRepoClient(3600);
1968
2093
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -1970,15 +2095,33 @@ function test332_cartridgeRepoClientGetCartridge() {
1970
2095
 
1971
2096
  client.updateCache('https://example.com/api/cartridges', cartridges);
1972
2097
 
1973
- const cartridge = client.getCartridge('pdfcartridge');
1974
- assert(cartridge !== null, 'Should find cartridge');
2098
+ const cartridge = client.getCartridge('release', 'pdfcartridge');
2099
+ assert(cartridge !== null && cartridge !== undefined, 'Should find cartridge in release');
1975
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');
1976
2106
 
1977
- const notFound = client.getCartridge('nonexistent');
1978
- 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');
1979
2121
  }
1980
2122
 
1981
- // 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.
1982
2125
  function test333_cartridgeRepoClientGetAllCaps() {
1983
2126
  const client = new CartridgeRepoClient(3600);
1984
2127
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -1988,11 +2131,14 @@ function test333_cartridgeRepoClientGetAllCaps() {
1988
2131
 
1989
2132
  const caps = client.getAllAvailableCaps();
1990
2133
  assert(Array.isArray(caps), 'Should return array');
1991
- 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}`);
1992
2137
  assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
1993
2138
  }
1994
2139
 
1995
- // 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.
1996
2142
  function test334_cartridgeRepoClientNeedsSync() {
1997
2143
  const client = new CartridgeRepoClient(1); // 1 second TTL
1998
2144
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -2008,12 +2154,10 @@ function test334_cartridgeRepoClientNeedsSync() {
2008
2154
 
2009
2155
  // Should not need sync immediately
2010
2156
  assert(client.needsSync(urls) === false, 'Should not need sync right after update');
2011
-
2012
- // Wait for cache to expire (1 second)
2013
- // Note: Can't test this synchronously, would need async test
2014
2157
  }
2015
2158
 
2016
- // 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.
2017
2161
  function test335_cartridgeRepoServerClientIntegration() {
2018
2162
  // Server creates API response
2019
2163
  const server = new CartridgeRepoServer(sampleRegistry);
@@ -2024,10 +2168,11 @@ function test335_cartridgeRepoServerClientIntegration() {
2024
2168
  const cartridges = apiResponse.cartridges.map(p => new CartridgeInfo(p));
2025
2169
  client.updateCache('https://example.com/api/cartridges', cartridges);
2026
2170
 
2027
- // Client can find cartridge
2028
- const cartridge = client.getCartridge('pdfcartridge');
2029
- 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');
2030
2174
  assert(cartridge.isSigned(), 'Cartridge should be signed');
2175
+ assert(cartridge.channel === 'release', 'Should report release channel');
2031
2176
  assert(cartridge.buildForPlatform('darwin-arm64') !== null, 'Cartridge should have darwin-arm64 build');
2032
2177
 
2033
2178
  // Client can get suggestions
@@ -2035,6 +2180,7 @@ function test335_cartridgeRepoServerClientIntegration() {
2035
2180
  const suggestions = client.getSuggestionsForCap(capUrn);
2036
2181
  assert(suggestions.length === 1, 'Should get suggestions');
2037
2182
  assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest correct cartridge');
2183
+ assert(suggestions[0].channel === 'release', 'Suggestion should preserve channel');
2038
2184
 
2039
2185
  // Server can search
2040
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.149.334"
43
+ "version": "0.152.345"
44
44
  }